# ERRORS.md — flat exit-code reference

Every Phoebe + TVM exit code, with the same prose served by `explainError(code)`. Generated from `src/errors.ts` (the canonical source of truth).

To explain a code at runtime:

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

CLI: `npx phoebe explain <code>` (or `--json` for machine output).

---

## Phoebe-specific (origin: `phoebe`)

### Access control / lifecycle (100 – 109)

| Code | Name | Message | Hint |
|------|------|---------|------|
| 100 | `NotOwner` | Caller is not the contract owner. | Send from the address recorded in `storage.owner` (read via `getOwnership().owner`). |
| 101 | `OperationPaused` | Contract is paused — `PushSnapshot` / `RequestPrice` reject work while paused. | Owner-admin receivers (pause/unpause/config/upgrade/withdraw/sync-atlas/cross-contract syncs) plus `ClaimReward` still work under pause — only new push/pull work is blocked. Wait for owner to call Unpause to resume the push/pull path. |
| 102 | `NotPaused` | `Unpause` was called when the contract is not paused. | Read `getPaused()` to confirm. Calling `Unpause` on an already-unpaused contract is a state-toggle noop. |
| 103 | `AlreadyPaused` | `Pause` was called when the contract is already paused. | Read `getPaused()` to confirm. Calling `Pause` on an already-paused contract is a state-toggle noop. |

### Schema / version (110 – 119)

| Code | Name | Message | Hint |
|------|------|---------|------|
| 110 | `BadSchemaVersion` | On-chain storage schema version does not match what this code expects. | SDK version and deployed contract version are out of sync. Upgrade either `@titon-network/phoebe-sdk` to match the live deploy, or the contract (via the 3-step ProposeCodeUpgrade flow). Check `getSchemaVersions()` against `PHOEBE_STORAGE_VERSION` + `PHOEBE_CONFIG_BLOB_VERSION`. |

### Group / groupId (130 – 131)

> Codes 132–147 are skipped per the Wallet V5 reserved range convention (see [CONVENTION.md](../../CONVENTION.md) §Errors).

| Code | Name | Message | Hint |
|------|------|---------|------|
| 130 | `GroupIdNotSupported` | `GroupKeySync` arrived with `groupId != 0`. | Single-group setup supports only the default group. Atlas should always fan out `groupId: 0`; a non-zero value means Atlas is mis-configured or has advanced to a multi-group setup ahead of Phoebe. |
| 131 | `InvalidThresholdConfig` | `GroupKeySync` `threshold`/`memberCount` fail `t > 0, n > 0, t <= n`. | Defense-in-depth: Atlas validates these at PublishGroupKey/Propose, so this should never fire from a healthy Atlas. If it does, check Atlas's `getGroupKey(0)` and rerun the rotation/bootstrap with a sane (t, n) pair. |

### Operator / submission + group-key monotonicity (140 – 149)

| Code | Name | Message | Hint |
|------|------|---------|------|
| 140 | `OperatorNotFound` | `PushSnapshot` sender is not in the mirrored operator map. | Operator must stake at ForgeTON and Phoebe must be admitted as a ForgeTON consumer there. ForgeTON fans `AutomatonSync` into Phoebe automatically when that happens. Check `getOperator(addr)`; call `sendSyncAtlas()` from owner if the initial fan-out was missed. |
| 141 | `OperatorNotActive` | Operator is mirrored but `isActive == false`. | ForgeTON deactivated the automaton (slash, explicit deactivate, or unstake-to-zero). Re-stake at ForgeTON — the AutomatonSync fan-out will mirror the new `isActive=true` into Phoebe. |
| 142 | `GroupEpochNotMonotonic` | `GroupKeySync` epoch is strictly lower than cached — stale replay. | Phoebe discards out-of-order `GroupKeySync` to prevent rollback to an older group key. Non-actionable — the cache is already on the newer epoch. |
| 143 | `GroupKeyChangedAtEpoch` | Same-epoch `GroupKeySync` re-publish must be bit-identical to the cached entry. | Atlas's `ForceSyncVerifier` (admin recovery) re-pushes the current epoch verbatim. If the new key/threshold/memberCount differ at the same epoch, that's a same-epoch substitution attack — Phoebe rejects to prevent rollback. Bump the epoch via Atlas's normal rotation flow if the key needs to change. |

### BLS / crypto (160 – 169)

| Code | Name | Message | Hint |
|------|------|---------|------|
| 161 | `InvalidBlsSignature` | `PushSnapshot` `aggSig` failed `BLS_VERIFY` against cached `groupPk`. | Most likely causes: (1) wrong DST off-chain — must be `BLS_DST_G2_POP` (`BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_`); (2) snapshot bytes drift — recompute via `computeSnapshotHash(phoebeAddress, timestamp, root)` using the EXACT destination address + (timestamp, root) the contract will see; (3) stale pkShares from a pre-rotation epoch — all signers must use the current-epoch share. Note: a wrong `phoebeAddress` reverts with `WrongDeploymentBinding` (144) BEFORE this check fires. |
| 162 | `InvalidG1Point` | Cached `groupPk` failed `BLS_G1_INGROUP` subgroup check. | Defensive guard against a malformed groupPk in the Atlas fan-out. Atlas itself subgroup-checks on its side; this should only fire if the message was tampered in flight. |

### Phoebe-specific — snapshot + Merkle proof + operator-claim + liveness (180 – 199)

| Code | Name | Message | Hint |
|------|------|---------|------|
| 180 | `GroupKeyNotSet` | `PushSnapshot` on a Phoebe deploy that has never received a `GroupKeySync` from Atlas. | Wait for Atlas bootstrap (happens automatically once owner admits Phoebe as a verifier via `atlas.sendSetVerifier`). Owner can also re-trigger via `sendSyncAtlas()` if the initial fan-out was missed. |
| 181 | `StaleSnapshot` | `PushSnapshot` `timestamp <= storage.lastSnapshotTime`, OR pull `maxStaleness` exceeded against the active root (cached OR fresh). | `PushSnapshot` must be strictly newer than the cached snapshot. `RequestPrice` with `maxStaleness != 0` rejects when `(now - activeSnapshotTime) > maxStaleness` — set `maxStaleness=0` to accept any active root, or attach a `freshUpdate` to satisfy a tight bound the cache would fail. |
| 182 | `BadTimestampDrift` | `PushSnapshot` OR `RequestPrice.freshUpdate` `timestamp` outside `±maxPushDrift` of `now`. | Future-dated payloads block pre-publishing; far-past block long-network-delay clobbers. Make sure the operator clock is in sync (NTP) and `maxPushDrift` covers your network jitter (default 300s = 5 minutes). |
| 183 | `BadMerkleProof` | `RequestPrice` proof failed: `proof.hash() != active root`, OR walked-leaf hash mismatch, OR `leaf.feedId != msg.feedId`. | Three causes — (1) stale proof: the active root rotated; rebuild against `getSnapshot().lastRoot` (or whatever root your fresh-update advances to). (2) tampered leaf: the claimed `PriceLeaf` doesn't hash to what the proof commits to. (3) feedId mismatch: `leaf.feedId` must equal `msg.feedId`. Use `PhoebeMerkleTree.proof(feedId)` for canonical proof construction. |
| 184 | _(deleted pre-deploy)_ | Was `FeedNotRegistered`; deleted alongside the feed registry. Pulls succeed for any `feedId` in `0..MAX_FEED_COUNT`. | — |
| 185 | _(deleted pre-deploy)_ | Was `DuplicateFeed`; deleted alongside the feed registry. Symbol display lives in operator-published off-chain config files. | — |
| 186 | `FeedIdOutOfRange` | `RequestPrice` `feedId >= MAX_FEED_COUNT` (256). | Phoebe supports feedIds 0..255 (8-bit dense Merkle tree at 8 levels deep). Bumping the cap ships via the standard timelocked code upgrade — new code lifts both `MAX_FEED_COUNT` and `TREE_DEPTH` and consumer wrappers update in lockstep. |
| 187 | `FreshNotFresher` | `RequestPrice.freshUpdate.timestamp <= cached lastSnapshotTime` — fresh must be strictly newer. | You attached a fresh signed snapshot, but the on-chain cache is already at-or-newer. Two causes: (1) someone else just landed an equally-fresh-or-newer push/fresh-update; (2) you grabbed a stale signed snapshot from your operator endpoint. Query `getSnapshot()` first and only attach `freshUpdate` when its timestamp strictly exceeds the on-chain `lastSnapshotTime` — or just use the cached fast-path (omit `freshUpdate`) and read whatever is current. |
| 188 | `NoUnclaimedReward` | `ClaimReward`: this operator has no accrued unclaimed balance. | Either the operator never pushed a snapshot that was followed by a successful `RequestPrice` (so they were never `lastSubmitter` while a pull accrued), or they already drained their balance to zero. Query `getUnclaimedReward(operatorAddress)` to confirm; only claim when it returns > 0. Map entries are deleted on full claim, so a re-claim immediately after a successful claim-all hits this code. |
| 189 | `InvalidOperatorBps` | `UpdateConfig.operatorBps` exceeds the basis-points cap (10000 = 100%). | `operatorBps` is the operator share of `pullFee` in basis points. Valid range is [0, 10000]. Default at deploy is 7500 (75%). 0 routes 100% of `pullFee` to `rewardPool` (owner-only). 10000 routes 100% to the operator unclaimed-rewards map. |
| 190 | `InvalidMaxInactivity` | `UpdateConfig.maxInactivity` exceeds `MAX_MAX_INACTIVITY` (24h = 86400 s). | Upper bound prevents a compromised owner from setting an absurdly large value that effectively disables the pull-side B filter while pretending to enable it. Valid range is [0, 86400]. 0 = disabled (no pull-side filter; silent `lastSubmitter` still gets the operator share). |

### Cross-contract auth (200 – 209)

| Code | Name | Message | Hint |
|------|------|---------|------|
| 200 | `NotForgeton` | `AutomatonSync` arrived from a sender that is not `storage.forgeton`. | Phoebe is pinned to one ForgeTON address at deploy (read via `getOwnership().forgeton`). To repoint, do a timelocked code upgrade — not configurable at runtime. |
| 201 | `NotAtlas` | `GroupKeySync` arrived from a sender that is not `storage.atlas`. | Phoebe is pinned to one Atlas address at deploy (read via `getOwnership().atlas`). To repoint, do a timelocked code upgrade. |

### Timelocks — code upgrade (220 – 229)

| Code | Name | Message | Hint |
|------|------|---------|------|
| 225 | `UpgradeEtaTooSoon` | `ProposeCodeUpgrade` `eta` is sooner than `now + MIN_UPGRADE_DELAY` (24h). | Set `eta >= now + 24 hours`. The SDK converts `delaySeconds: 86400+` into the absolute timestamp at send time. |
| 226 | `UpgradeNotReady` | `ExecuteCodeUpgrade` called before the pending `eta` has elapsed. | Wait until block time >= the proposal `eta` (24h minimum after Propose). Check `getPendingUpgrade()` for the eta. |
| 227 | `UpgradeAlreadyPending` | `ProposeCodeUpgrade` while another proposal is still pending. | Cancel the existing proposal first (`sendCancelCodeUpgrade`) if the new config should supersede it. |
| 228 | `NoPendingUpgrade` | `ExecuteCodeUpgrade` / `CancelCodeUpgrade` called with no proposal pending. | Call `sendProposeCodeUpgrade` first, wait 24h, then execute. |
| 229 | `EmptyCodeCell` | `ProposeCodeUpgrade` rejected because `newCode` cell has zero bits AND zero refs. | Pass a non-empty `Cell` built from the new contract bytecode (loaded via `Cell.fromBoc` or Blueprint's compile output). |

### Reserve / value (240 – 249)

| Code | Name | Message | Hint |
|------|------|---------|------|
| 240 | `InsufficientValue` | `msg.value` below the required gas floor for this receiver. | `PushSnapshot` needs ≥ `MIN_GAS_FOR_PUSH` (0.05 TON). `RequestPrice` needs ≥ `pullFee + MIN_GAS_FOR_PULL` (0.03 TON). `SyncAtlas` / upgrade ops need `MIN_GAS_FOR_SYNC_REQUEST` / `MIN_GAS_FOR_UPGRADE`. Cross-contract / owner-admin ops need `MIN_XC_GAS` (0.01 TON). Use `estimatePullValue` / `estimatePushValue` from the SDK. |
| 241 | `ReserveInvariant` | Operation would breach `minStorageReserve + rewardPool` floor. | Usually indicates pool-accounting drift (a code-path failed to update `rewardPool` correctly) or a fresh deploy with insufficient seed funding. Top up the contract balance or file a bug — should never fire under normal operation. |
| 242 | `AmountExceedsAccumulated` | `WithdrawFees` `amount` exceeds `rewardPool` — would underflow the accumulator. | Read the current withdrawable balance via `getConfig().rewardPool` and ask for ≤ that. |
| 243 | `BadWithdrawTarget` | `WithdrawFees` `to` is the Phoebe contract's own address — would strand fees as untracked balance excess. | Pick a destination distinct from the Phoebe contract itself (typically the owner address or a treasury). |

### TEP-convention exit codes (outside 100 – 249)

| Code | Name | Message | Hint |
|------|------|---------|------|
| 333 | `WrongWorkchain` | Phoebe is workchain-0-only by titon convention; this contract is on a different workchain. | Hard invariant — every `PushSnapshot` fails until the contract is redeployed on workchain 0. Companion to `WrongDeploymentBinding` (144): without this pin, two same-code Phoebes on different workchains would share an address-hash and accept each other's pushes. Matches atlas's `E_WRONG_WORKCHAIN` (333) — deliberately outside the 100-249 user range for cross-protocol consistency. |

### Dispatcher fallthrough

| Code | Name | Message | Hint |
|------|------|---------|------|
| 0xFFFF | `UnknownOpcode` | Dispatcher rejected an unknown opcode. | Make sure you're sending to Phoebe and that the opcode is in the SDK's `OP` table (`import { OP } from '@titon-network/phoebe-sdk'`). Fat-fingered opcodes return 0xFFFF rather than silently accepting. Empty-body inbounds (top-up path) are exempt. |

---

## Common TVM codes (origin: `tvm`)

These come from the TVM runtime, not Phoebe specifically. Phoebe's surface forwards them verbatim.

| Code | Name | Notes |
|------|------|-------|
| 2 | `StackUnderflow` | Likely malformed message body. |
| 3 | `StackOverflow` | — |
| 4 | `IntegerOverflow` | Arithmetic overflow during compute. |
| 5 | `IntegerOutOfRange` | Integer out of declared range. |
| 7 | `TypeCheckError` | TVM type check failed. |
| 8 | `CellOverflow` | Too many bits / refs in a cell. |
| 9 | `CellUnderflow` | Slice ran out of data while parsing. |
| 13 | `OutOfGas` | Increase the value attached to the message. PushSnapshot's BLS_VERIFY dominates gas — budget ≥ 0.05 TON. |
| 32 | `ActionListInvalid` | — |
| 33 | `ActionListTooLong` | — |
| 34 | `ActionInvalid` | — |
| 35 | `InvalidSrcAddress` | — |
| 36 | `InvalidDstAddress` | Likely workchain mismatch or undeliverable consumer. |
| 37 | `NotEnoughTon` | Contract balance too low for an outbound. Top up. |
| 38 | `NotEnoughExtra` | — |
| 40 | `NotEnoughForOp` | — |
| 50 | `AccountSizeExceeded` | — |

---

## Cross-contract forwarding

When a Phoebe flow bounces from a peer contract:

| Range | Origin | Resolve via |
|-------|--------|-------------|
| 150 – 169 | ForgeTON (operator / consumer admission) | `import { explainError } from '@titon-network/forgeton-sdk'` |
| 210 – 229 | Atlas (verifier admission / rotation timelock) | `import { explainError } from '@titon-network/atlas-sdk'` |

Phoebe's `explainError(code, 'forgeton'|'atlas')` returns a forwarding hint (the symbolic name belongs to the peer SDK).
