---
name: phoebe-debug
description: Diagnose a failed Phoebe transaction — exit codes, schema drift, missing event, stuck operator. Use when the user says "my push failed", "RequestPrice reverts", "exit code N", "FulfillPrice never landed", or anything resembling a debugging request.
---

# Debug Phoebe

Triage a failure in three steps: (1) get the exit code, (2) explain it, (3) act.

## Step 1 — get the exit code

If you have a sandbox `Transaction`:

```typescript
import { summarizeTx, formatTxSummary } from '@titon-network/phoebe-sdk';

for (const tx of result.transactions) {
    console.log(formatTxSummary(summarizeTx(tx)));
}
```

If you have a live tx hash:

```bash
# toncenter:
curl https://testnet.toncenter.com/api/v2/getTransactions?address=<addr>&limit=1
# → tx body has tx.compute.exitCode at the top level
```

If you have an `Error` from the SDK:

```typescript
try { ... } catch (e) {
    if (e instanceof PhoebeError) {
        console.error(`[${e.code}] ${e.name}: ${e.message}`);
        if (e.hint) console.error(`hint: ${e.hint}`);
    }
}
```

## Step 2 — explain it

```bash
npx phoebe explain 161
# → exit 161 (phoebe): InvalidBlsSignature
#     PushSnapshot aggSig failed BLS_VERIFY against cached groupPk.
#     hint: Most likely causes: …
```

Or programmatically:

```typescript
import { explainError } from '@titon-network/phoebe-sdk';
const e = explainError(161);
// → { code: 161, origin: 'phoebe', name: 'InvalidBlsSignature',
//     message: '…', hint: '…' }
```

For the flat reference table: [`ERRORS.md`](../ERRORS.md).

## Step 3 — act

Pick the row matching your symptom + cause + fix.

### Push-side failures (operator is the actor)

| Code | Symptom | Fix |
|------|---------|-----|
| 240 `InsufficientValue` | Push reverts at entry | `value` < `MIN_GAS_FOR_PUSH` (0.05 TON). Use `estimatePushValue()`. |
| 161 `InvalidBlsSignature` | Push reverts post-entry | (1) Wrong DST — use `signMessage(sk, ...)`. (2) Hash drift — `computeSnapshotHash(phoebe.address, ts, root)` must match the EXACT (phoebe address, ts, root) you submit. (3) Stale pkShare from a pre-rotation epoch — update to the current-epoch share. |
| 144 `WrongDeploymentBinding` | Push reverts post-entry, before BLS verify | Signed payload's `phoebeHash` doesn't match the destination contract's address hash. Either: (a) operator pusher mis-configured (signing for the wrong Phoebe address); or (b) someone is replaying a sig from another Phoebe sharing the same Atlas group. Pass the destination's `Address` to `computeSnapshotHash`. |
| 181 `StaleSnapshot` | Push reverts | `timestamp <= storage.lastSnapshotTime`. Another operator pushed first; use a strictly newer timestamp. |
| 182 `BadTimestampDrift` | Push reverts | `|now - timestamp| > maxPushDrift` (300s default). Sync operator clock (NTP); raise `maxPushDrift` if network jitter dominates. |
| 140 `OperatorNotFound` | Push reverts | Not mirrored from ForgeTON. Check `phoebe.getOperator(addr)`; ensure Phoebe is admitted as a ForgeTON consumer; owner can `sendSyncAtlas()` to re-trigger. |
| 141 `OperatorNotActive` | Push reverts | Mirrored but `isActive=false`. Re-stake at ForgeTON; AutomatonSync will re-mirror with `isActive=true`. |
| 180 `GroupKeyNotSet` | Push reverts | Atlas hasn't bootstrapped Phoebe's cache. Owner: `phoebe.sendSyncAtlas(...)`. Verify with `phoebe.getGroupKey()` (should be non-null after). |

### Pull-side failures (consumer is the actor)

`RequestPrice` has two modes: A (cached fast-path, ~13k gas, default) and B (fresh-update, ~73k gas, opt-in via `freshUpdate` field). The same numeric exit code can fire on either path; the column below tells you which.

| Code | Symptom | Mode | Fix |
|------|---------|------|-----|
| 240 `InsufficientValue` | Pull reverts at entry | A or B | `value` < `cfg.pullFee + MIN_GAS_FOR_PULL`. For mode A: `estimatePullValue({ pullFee: cfg.pullFee })`. For mode B: pass `withFresh: true` (adds 0.05 TON for the BLS_VERIFY budget). |
| 183 `BadMerkleProof` | Pull reverts post-entry | A or B | Three causes: (1) stale proof — rebuild against the active root (cached `lastRoot` for A; `freshUpdate.root` for B). (2) tampered leaf — pass the same `PriceLeaf` you used to build the tree. (3) feedId mismatch — `leaf.feedId` must equal `msg.feedId`. |
| 181 `StaleSnapshot` | Pull reverts | A or B | `maxStaleness` exceeded against the active root. Set `maxStaleness=0` to accept any active root, OR wait for a fresh push, OR attach a `freshUpdate` (mode B) to satisfy a tight bound the cache would fail. |
| 101 `OperationPaused` | Pull reverts | A or B | Owner paused the contract. Both modes are pause-blocked. Wait for `Unpause`. |
| 187 `FreshNotFresher` | Mode-B pull reverts post-entry | B | `freshUpdate.timestamp <= cached lastSnapshotTime` — fresh must be strictly newer. Query `phoebe.getSnapshot()` first; if cache is at-or-newer, omit `freshUpdate` and use mode A. Most common cause: operator endpoint is lagging or another consumer just landed an equal/newer fresh-update in the same block. |
| 144 `WrongDeploymentBinding` | Mode-B pull reverts pre-BLS-verify | B | Same as the push-side row 144 above — fresh-update's signed `phoebeHash` doesn't match this Phoebe's address-hash. Either the operator endpoint is signing for a different Phoebe deployment (mis-config), or someone is replaying a sig from another Phoebe sharing the same Atlas group. Re-fetch from the right operator endpoint. |
| 182 `BadTimestampDrift` | Mode-B pull reverts | B | Same as push-side row 182 — fresh-update's `timestamp` outside `±maxPushDrift`. Pull a more recent signed snapshot; check operator clock sync. |
| 161 `InvalidBlsSignature` | Mode-B pull reverts | B | Same as push-side row 161 — fresh-update's BLS sig doesn't verify under cached `groupPk`. Most likely the operator endpoint serves a stale/wrong-key sig; re-fetch. (Cached path A never runs BLS verify, so 161 only fires on mode B for pulls.) |
| 180 `GroupKeyNotSet` | Mode-B pull reverts | B | Atlas hasn't bootstrapped Phoebe's cache yet. Owner: `phoebe.sendSyncAtlas(...)` to re-trigger fan-out. (Mode A doesn't need the group key cached — it just walks the proof against `lastRoot`.) |

### Cross-contract failures (Atlas / ForgeTON inbound)

| Code | Symptom | Fix |
|------|---------|-----|
| 200 `NotForgeton` | AutomatonSync rejected | Sender ≠ `storage.forgeton`. Phoebe is pinned at deploy; needs code upgrade to repoint. Check `phoebe.getOwnership().forgeton`. |
| 201 `NotAtlas` | GroupKeySync rejected | Sender ≠ `storage.atlas`. Same — pinned at deploy. |
| 130 `GroupIdNotSupported` | GroupKeySync rejected | Atlas sent `groupId != 0`. Single-group setup supports only group 0; debug at the Atlas side. |
| 131 `InvalidThresholdConfig` | GroupKeySync rejected | `t == 0`, `n == 0`, or `t > n`. Atlas should reject these at its border; if Phoebe sees them, Atlas is mis-configured. |
| 142 `GroupEpochNotMonotonic` | GroupKeySync rejected | Stale replay — cache is already on the newer epoch. Non-actionable. |
| 143 `GroupKeyChangedAtEpoch` | GroupKeySync rejected | Same-epoch republish has different fields. Atlas should re-push EXACT cached state for force-sync; debug at the Atlas side. |

### Owner-op failures

| Code | Symptom | Fix |
|------|---------|-----|
| 100 `NotOwner` | Owner op rejected | Send from `storage.owner` — read via `phoebe.getOwnership().owner`. |
| 102 `NotPaused` | `Unpause` rejected | Already unpaused. State-toggle noop. |
| 103 `AlreadyPaused` | `Pause` rejected | Already paused. State-toggle noop. |
| 225 `UpgradeEtaTooSoon` | `ProposeCodeUpgrade` rejected | `eta < now + 24h`. Pass `delaySeconds: 86400+`. |
| 226 `UpgradeNotReady` | `ExecuteCodeUpgrade` rejected | Wait for the timelock (`now < eta`). |
| 227 `UpgradeAlreadyPending` | `ProposeCodeUpgrade` rejected | Cancel the existing proposal first. |
| 228 `NoPendingUpgrade` | `Execute` / `Cancel` rejected | No proposal in flight. |
| 229 `EmptyCodeCell` | `ProposeCodeUpgrade` rejected | `newCode` cell empty. Pass real bytecode. |
| 242 `AmountExceedsAccumulated` | `WithdrawFees` rejected | Read `getConfig().rewardPool`; ask for ≤ that. |
| 243 `BadWithdrawTarget` | `WithdrawFees` rejected | `to == phoebeAddr` would strand fees. Pick a distinct destination. |
| 189 `InvalidOperatorBps` | `UpdateConfig` rejected | `operatorBps > 10000`. Valid range is `[0, 10000]`. 10000 = 100% to operator; 0 = 100% to rewardPool. |

### Operator-claim failures

| Code | Symptom | Fix |
|------|---------|-----|
| 140 `OperatorNotFound` | `ClaimReward` rejected | Sender is not in `storage.operators` mirror. Stake at ForgeTON; ensure Phoebe is admitted as a consumer; check `phoebe.getOperator(addr)`. |
| 188 `NoUnclaimedReward` | `ClaimReward` rejected | Balance is zero (never accrued, or already drained). Query `phoebe.getUnclaimedReward(addr)` first; only claim when it returns > 0. Map entries are deleted on full claim — an immediate re-claim hits this code, not a zero return. Also fires if you push but no consumer has pulled against your snapshot yet (accrual is pull-time, not push-time). |
| 242 `AmountExceedsAccumulated` | `ClaimReward` rejected | Non-zero `amount > unclaimed balance`. Use `amount: 0n` (claim-all) or pass exactly `phoebe.getUnclaimedReward(addr)`. |
| 243 `BadWithdrawTarget` | `ClaimReward` rejected | `to == phoebe.address` would create phantom funds (decrement is real, payout goes to self). Route to operator's wallet, treasury, or any other address. |
| 240 `InsufficientValue` | `ClaimReward` rejected | `value < MIN_GAS_FOR_CLAIM` (0.03 TON). Attach 0.05 TON for slack. |

### Schema-drift failures

```bash
npx phoebe verify --testnet
```

Output:

```
Verifying testnet Phoebe @ 0:abcd…
  ✗ schema drift detected:
    - storage version: live=2 expected=1
  Action: upgrade @titon-network/phoebe-sdk to a version that ships the live schema, OR follow skills/phoebe-debug.md.
```

If the SDK is older than the live contract: `npm install @titon-network/phoebe-sdk@latest`.

If the contract is older than the SDK: a code upgrade was proposed but not executed (or the upgrade is from a different SDK version). Check `phoebe.getPendingUpgrade()` + bisect.

### "Pull worked but FulfillPrice never landed"

Symptom: `RequestPrice` succeeded, but the consumer's `handleFulfillPrice` never fired.

Causes:

1. **Consumer reverts on receive.** Phoebe sends with `bounce: false`; a revert at the consumer doesn't bounce back to Phoebe — the request is finalized at Phoebe's side regardless. Check the consumer's tx exit code.
2. **Wrong opcode.** Consumer's receiver must be `struct (0x00000072) FulfillPrice`. Wrong opcode → 0xFFFF (UnknownOpcode) at the consumer.
3. **Wrong workchain.** Phoebe + consumer on different workchains → action 36 at Phoebe (you'd see it in the push tx's outMessages). Pin both to workchain 0.
4. **Insufficient consumer-side gas.** Phoebe forwards what's left after `pullFee` is retained. If your callback is heavy + you didn't attach enough, exit 13 (OutOfGas) at the consumer. Increase `value` on the original `RequestPrice`.

### "Operator stuck — no pushes succeeding"

Bisect:

```typescript
// 1. Is the operator mirrored?
const op = await phoebe.getOperator(operatorAddr);
console.log(op);   // should be { isActive: true }

// 2. Does Phoebe have a group key?
const gk = await phoebe.getGroupKey();
console.log(gk);   // should be non-null

// 3. Is the operator's pkShare aligned with the cached groupPk?
//    Solo-mode: must be equal. Multi-op: must be a current-epoch share.
const operatorPk = blsPublicKey(operatorSk);
console.log(operatorPk.equals(gk.groupPk));   // expect true in solo mode

// 4. Try a sandbox push first — if it succeeds, the issue is environmental
//    (clock, network, gas) not signing.
const fx = await deployPhoebeFixture(blockchain, { now: liveTimestamp });
const { result } = await fx.pushSnapshot({ leaves: new Map(...) });
console.log(formatTxSummary(summarizeTx(result.transactions[0])));
```

### "Indexer says an unknown operator pushed" (false alarm — it's mode B)

`EvtSnapshotPushed` fires on **two** code paths:

1. Operator's `PushSnapshot` (0x70) — `submitter` = operator's address (in your ForgeTON-mirrored set).
2. Consumer's `RequestPrice` with `freshUpdate` (mode B, 0x71) — `submitter` = consumer's address (NOT in your operator set; permissionless caller).

If your indexer's "who pushed?" query returns a non-operator address, it's a mode-B advance — a consumer paid the BLS-verify gas to land a fresh root inline with their pull. This is expected. Disambiguate by joining `EvtSnapshotPushed.submitter` against `phoebe.getOperator(submitter)`: if the result is `{ isActive: true }`, it's an operator push; if `null`, it's a consumer-driven mode-B advance.

## Watch out for

- **Don't retry blindly.** A failed push burns gas; investigate the exit code first.
- **Don't ignore `NotForgeton` / `NotAtlas`.** These mean Phoebe was deployed pointing at the WRONG peer addresses — a deploy-time configuration bug. Don't try to "fix" by re-syncing; you need a code upgrade to repoint the addresses.
- **Don't mix up timestamp + epoch.** `timestamp` (uint32 unix seconds) is the snapshot's wall-clock time, drift-bounded against `now`. `groupEpoch` (uint32) is Atlas's rotation counter, monotonic. Different concepts.
- **Don't trust an unauthenticated `FulfillPrice` body.** Always assert sender in your consumer's receiver.

## Cross-references

- [`ERRORS.md`](../ERRORS.md) — flat error table with per-code prose
- [`AGENTS.md`](../AGENTS.md) — full SDK surface
- [`phoebe-operator-setup.md`](./phoebe-operator-setup.md) — operator-side issues
- [`phoebe-integrate-consumer.md`](./phoebe-integrate-consumer.md) — consumer-side issues
- [`phoebe-owner-ops.md`](./phoebe-owner-ops.md) — owner-side issues
