# @titon-network/phoebe-sdk — full prose digest > Long-form companion to [`llms.txt`](./llms.txt). For AI assistants that want the complete story before generating code: protocol shape, persona walkthroughs, common failure modes, and the testing model. --- ## Section 1 — What Phoebe is Phoebe is a TON-native price oracle. The on-chain contract holds: - A **BLS-aggregated Merkle root** over all 256 feed leaves, refreshed by operator pushes (~30s cadence). - The **cached group public key** from Atlas (`groupPk`, `groupEpoch`, `threshold`, `memberCount`). - The **operator mirror** from ForgeTON (`isActive` per address). - The **`rewardPool`** — owner share (default 25%) of `pullFee` revenue; owner-withdrawable via `WithdrawFees`. - The **`operatorRewards` map** — sparse per-operator unclaimed-rewards; operator share (default 75%) of every accepted `RequestPrice` credits the most-recent pusher's entry; drained via `ClaimReward`. - The **`operatorBps` tunable** (owner-set) + **`lastSubmitter` field** (handler-set on every `PushSnapshot`) — the two halves of the per-push proposer-reward split. - The **`maxInactivity` tunable** (owner-set; default 0 = disabled) — pull-side filter on the operator-share split. When `maxInactivity > 0` AND the `lastSubmitter`'s `lastPushAt` is older than `now - maxInactivity`, the operator share rolls into `rewardPool` rather than accruing to the silent op. Phoebe stays carrot-only — there is no `Slash` outbound from Phoebe. Symbol display (feedId → human-readable name, decimals hint) lives in operator-published off-chain config files (`feeds.json`); the on-chain verify path is feedId-registration-agnostic. Architecturally, Phoebe is a **verifier consumer**: - **Atlas** owns the group key, fans out `GroupKeySync (0x51)` to admitted verifiers on rotation. - **ForgeTON** owns the operator stake + slashing, fans out `AutomatonSync (0x1A)` on operator state changes. - **Phoebe** owns price snapshots + the `RequestPrice` / `FulfillPrice` consumer ABI. Pricing is BLS-attested: operators threshold-sign `(timestamp, root)` off-chain; the first valid `PushSnapshot` wins. Consumers attach a Merkle proof + claimed leaf to `RequestPrice` and choose a mode: - **Mode A — cached fast-path (~13k gas).** `freshUpdate` omitted. Phoebe walks the proof against `cfg.lastRoot`. Use this for routine reads where the ~30s heartbeat freshness is fine. - **Mode B — fresh-update path (~73k gas, Pyth-style update + read).** `freshUpdate` set with `{ signedData: 68B, sig: 96B }`. Phoebe BLS-verifies the attached snapshot under Atlas's cached `groupPk`, advances the cache (positive externality — subsequent same-block pulls read the advanced cache for free), and walks the proof against the fresh root. Permissionless caller — the BLS aggregate is the auth. Use this for high-stakes reads (lending liquidations, perps) where 30s freshness isn't enough. Both modes fire `FulfillPrice { queryId, leaf }` back at the consumer. There is **no slashing** in Phoebe — operators self-opt-out by simply not pushing. Liveness is gas-economic: every accepted `RequestPrice` splits `pullFee` between the latest pusher's `operatorRewards` entry (default 75%) and `rewardPool` (default 25%), so fresh pushers naturally capture the income stream from the next batch of pulls. The owner-tunable `maxInactivity` filter rolls a silent operator's would-be share into `rewardPool` rather than accruing to a non-functioning node. Bad-data slashing is governance-subjective and deferred (matches Fortuna's stance per fortuna's D-011). --- ## Section 2 — How a consumer works You're writing a TON dapp that needs prices. The integration shape: ``` [your dapp] ──RequestPrice (0x71)──▶ [Phoebe] ──FulfillPrice (0x72)──▶ [your dapp] ``` ### Step 1: pin the wire types Copy `templates/consumer.tolk` (or run `npx phoebe scaffold consumer`). The template defines: ```tolk struct PriceLeaf { feedId: uint16 mantissa: int128 // signed; covers BTC × 10^8 with headroom expo: int8 confBps: uint16 // confidence interval, basis points of price pubTime: uint32 // unix seconds when off-chain aggregator produced this } struct (0x00000072) FulfillPrice { queryId: uint64; leaf: PriceLeaf; } struct (0x00000071) RequestPrice { queryId: uint64; feedId: uint16; proofRef: cell; leaf: PriceLeaf; maxStaleness: uint32; freshUpdateRef: Cell?; // optional Pyth-style update + read } struct FreshUpdate { signedDataRef: Cell // 68B: phoebeHash + ts + root aggSigRef: Cell // 96B: BLS-G2 aggregate } ``` These shapes are **frozen** — never edit field order or widths. `freshUpdateRef` is optional (`null` = cached fast-path, mode A; set = fresh-update path, mode B). ### Step 2: build the proof off-chain Phoebe's snapshot is a 256-leaf binary Merkle tree. To pull feed N, your off-chain code (or your indexer) needs: 1. The latest signed leaves the operator pushed. 2. A proof from the leaf at slot N up to the root. The SDK provides `PhoebeMerkleTree`: ```typescript import { PhoebeMerkleTree } from '@titon-network/phoebe-sdk'; const myLeaf = { feedId: 42, mantissa: 100n, expo: 0, confBps: 50, pubTime: 2_000_000_000, }; const tree = PhoebeMerkleTree.fromSparseLeaves(new Map([[42, myLeaf]])); const proof = tree.proof(42); // pruned-branch merkle_proof (~417 B per pull) ``` In production you'd fetch `myLeaf` from an indexer that watches `EvtSnapshotPushed` events + has the original leaves the operator signed. The on-chain `verifyAndUnwrapProof` reads the merkle_proof wrapper's stored hash (via the `XCTOS` opcode) and binds it against `lastRoot`. For callers that can't construct exotic cells, `tree.fullTreeProof(42)` returns the legacy full-tree shape (~8.5 KB) and the on-chain code accepts both. ### Step 3: send `RequestPrice` ```typescript import { Phoebe, estimatePullValue, PHOEBE_DEFAULTS } from '@titon-network/phoebe-sdk'; const phoebe = tonClient.open(Phoebe.createFromAddress(myPhoebeAddr)); await phoebe.sendRequestPrice(myConsumerSender, { value: estimatePullValue({ pullFee: PHOEBE_DEFAULTS.pullFee }), queryId: 1n, feedId: 42, proof, leaf: myLeaf, maxStaleness: 0, }); ``` Phoebe will: 1. Assert `msg.value >= cfg.pullFee + MIN_GAS_FOR_PULL` (0.04 TON default). 2. Optionally assert `(now - lastSnapshotTime) <= maxStaleness`. 3. Assert `proof.hash() == cfg.lastRoot`. 4. Walk the proof to slot `feedId`, assert the walked-leaf hash matches `leaf.toCell().hash()`. 5. Assert `leaf.feedId == msg.feedId`. 6. Bump `cfg.rewardPool += cfg.pullFee`. 7. Send `FulfillPrice { queryId, leaf }` to the consumer with `bounce: false`. If any assertion fails, the entire pull reverts; nothing changes on-chain. ### Step 3b (optional): attach a fresh-update for sub-heartbeat freshness If your protocol can't tolerate a 30-second-old price (lending liquidations, perps), attach a freshly signed snapshot inline. Phoebe BLS-verifies it, advances the cache, and walks the proof against the fresh root. ```typescript import { computeSnapshotHash, signMessage, estimatePullValue } from '@titon-network/phoebe-sdk'; // Query operator's signed-data feed for a snapshot newer than what's on-chain const fresh = await operatorClient.getSignedSnapshot(); // { timestamp, root, sig } const { lastSnapshotTime } = await phoebe.getSnapshot(); if (fresh.timestamp <= lastSnapshotTime) { // Cache is already at-or-newer — fall through to mode A. // (Attaching a non-fresher fresh would revert with E_FRESH_NOT_FRESHER (187).) return await pullCached(); } // Build proof against the FRESH root (not the cached one) const freshTree = PhoebeMerkleTree.fromSparseLeaves(freshLeaves); const signedData = computeSnapshotHash(phoebe.address, fresh.timestamp, fresh.root); await phoebe.sendRequestPrice(myConsumerSender, { value: estimatePullValue({ pullFee: PHOEBE_DEFAULTS.pullFee, withFresh: true }), queryId: 2n, feedId: 42, proof: freshTree.proof(42), // proof against fresh.root leaf: freshLeaves.get(42), maxStaleness: 0, freshUpdate: { signedData, sig: fresh.sig }, }); // Phoebe runs (in this order): // - workchain pin + phoebeHash binding (cross-deployment defense) // - strict monotonic: fresh.ts > cached.lastSnapshotTime (else E_FRESH_NOT_FRESHER, 187) // - drift bound (±maxPushDrift) // - group key cached + BLS_VERIFY (~60k gas) // - advance cache: cfg.lastSnapshotTime, cfg.lastRoot ← fresh values // - then exactly the same Merkle walk + leaf checks + pull-fee accounting as mode A // - emits BOTH EvtSnapshotPushed (submitter = consumer) AND EvtPricePulled ``` Permissionless caller — anyone can submit a fresh update; the BLS aggregate is the auth. Cache advances atomically before the Merkle walk, so subsequent same-block pulls read the advanced cache for free (positive externality). ### Step 4: handle `FulfillPrice` in your consumer ```tolk fun handleFulfillPrice(msg: FulfillPrice, sender: address) { var storage = lazy ConsumerStorage.load(); assert (sender == storage.phoebe) throw E_NOT_PHOEBE; // YOUR BUSINESS LOGIC: msg.leaf is the verified PriceLeaf. // - msg.leaf.mantissa × 10^msg.leaf.expo = price // - msg.leaf.confBps = uncertainty, basis points of price // - msg.leaf.pubTime = when off-chain aggregator produced this settle(msg.queryId, msg.leaf.mantissa, msg.leaf.expo); storage.save(); } ``` The `assert (sender == storage.phoebe)` is non-negotiable — without it, anyone can deliver a fake `FulfillPrice` body and forge the price. --- ## Section 3 — How an operator works You're running an automaton that pushes signed price snapshots to Phoebe. ### Pre-conditions Before you can push: 1. **Stake at ForgeTON** — `forgeton.sendRegisterAutomaton(operatorVia, { value: STAKE_VALUE })`. This activates your address in ForgeTON's automaton map. 2. **Get admitted at Phoebe** — the Phoebe owner runs `forgeton.sendSetConsumer(...)` to admit Phoebe as a ForgeTON consumer. Once admitted, ForgeTON fan-outs `AutomatonSync` for your address into Phoebe → `OperatorMirrored { isActive: true }`. 3. **Register a pkShare at Atlas** — `atlas.sendRegisterBlsShare(operatorVia, { pkShare: blsPublicKey(yourSk) })`. In solo-mode (n=1), Atlas asserts `pkShare == groupPk`. 4. **Aggregate prices off-chain** — pull from CEX/DEX APIs, compute weighted median per feed, pack into `PriceLeaf`. ### The push loop ```typescript import { Phoebe, PhoebeMerkleTree, computeSnapshotHash, signMessage, estimatePushValue, } from '@titon-network/phoebe-sdk'; async function pushOnce() { // 1. Build the snapshot tree from your latest aggregated prices const tree = PhoebeMerkleTree.fromSparseLeaves(new Map([ [0, { feedId: 0, mantissa: btcPrice * 100_000_000n, expo: -8, confBps: 50, pubTime: now() }], [1, { feedId: 1, mantissa: ethPrice * 100_000_000n, expo: -8, confBps: 50, pubTime: now() }], // ... up to 256 slots ])); const root = tree.rootAsBigint(); const timestamp = Math.floor(Date.now() / 1000); // 2. Sign (timestamp, root) with your pkShare secret const sigInput = computeSnapshotHash(phoebe.address, timestamp, root); const aggSig = signMessage(operatorSk, sigInput); // 3. Submit. `value` covers gas + slack; excess refunds via mode 64. await phoebe.sendPushSnapshot(operatorSender, { value: estimatePushValue(), // 0.1 TON default timestamp, root, aggSig, }); } setInterval(pushOnce, 30_000); // ~30s cadence ``` ### Multi-op coordination For multi-op groups (n > 1), each operator computes their own partial sig: ```typescript const myPartial = signMessage(myShareSk, sigInput); ``` Then a designated aggregator combines all partials: ```typescript import { aggregateSignatures } from '@titon-network/phoebe-sdk'; const aggSig = aggregateSignatures([partial1, partial2, ...]); ``` Phoebe's on-chain BLS_VERIFY checks the aggregate as a single (pk, sig, msg) tuple against Atlas's cached `groupPk` (which Atlas pre-aggregated). Costs are constant in `n`. The off-chain coordination (which operator aggregates, how partials are exchanged) is the operator daemon's job; the on-chain shape is identical for solo vs multi-op. ### Watch out for - **Clock drift.** `BadTimestampDrift` (182) fires if `|now - timestamp| > maxPushDrift` (300s default). Run NTP. - **Stale pushes.** `StaleSnapshot` (181) if `timestamp <= storage.lastSnapshotTime`. Two operators racing to push the same timestamp will see the second one revert; coordinate via `submissionJitter` (Fortuna pattern) or accept the race. - **Wrong DST.** The #1 cause of `InvalidBlsSignature` (161). Always use `signMessage` from this SDK (or pin `BLS_DST_G2_POP` manually). - **Pre-rotation pkShares.** When Atlas rotates the group key, all pre-rotation shares are invalidated. Operators must update their `groupSecret` at every rotation. ### Claiming accrued rewards Every successful `RequestPrice` after your push credits your operator address with `pullFee * operatorBps / 10000` of the consumer's fee (default 75%; the remainder lands in `rewardPool` for the owner). Your share accrues in the contract's `operatorRewards` map until the next operator displaces you as `lastSubmitter` — but your accrued balance is preserved indefinitely after that. Claim when economical (claim costs ~0.03 TON gas, so let the balance grow first). ```typescript import { toNano } from '@ton/core'; const unclaimed = await phoebe.getUnclaimedReward(operatorAddress); if (unclaimed > toNano('0.1')) { // arbitrary threshold await phoebe.sendClaimReward(operatorSender, { value: toNano('0.05'), // covers MIN_GAS_FOR_CLAIM (0.03 TON) to: payoutAddress, // can be a cold treasury amount: 0n, // 0n = claim entire balance }); } ``` Reverts: - `OperatorNotFound (140)` — your sending address isn't in the operator mirror map (you never staked at ForgeTON, OR ForgeTON's AutomatonSync to Phoebe hasn't landed yet). - `NoUnclaimedReward (188)` — balance is zero (never accrued, or already drained, or you just pushed but no consumer has pulled yet — the accrual happens at pull-time, not push-time). - `AmountExceedsAccumulated (242)` — non-zero `amount` exceeds the current balance. - `BadWithdrawTarget (243)` — `to` is the Phoebe contract's own address (would create phantom funds). "Earned is earned": even if ForgeTON deactivates you (`isActive: false`), accrued balances stay claimable. Mode B (consumer-attached fresh updates) does NOT change `lastSubmitter` — consumers cannot route the operator share to themselves; you keep accruing until a real operator push displaces you. The owner-tunable `maxInactivity` filter, when enabled, rolls your would-be share into `rewardPool` once you go silent past the threshold — keep pushing on cadence to actually earn the operator share. --- ## Section 4 — How an owner works You're operating a Phoebe deployment. ### Deploy ```typescript import { newPhoebe } from '@titon-network/phoebe-sdk'; const phoebe = tonClient.open(newPhoebe({ owner: ownerAddr, forgeton: forgetonAddr, atlas: atlasAddr, tunables: { pullFee: toNano('0.01'), maxPushDrift: 300 }, })); await phoebe.sendDeploy(ownerSender, toNano('1')); // 1 TON funding ``` Phoebe's storage is now initialized; the cross-references (`forgeton`, `atlas`) are pinned. ### Wire to Atlas + ForgeTON ```typescript // 1. Atlas owner admits Phoebe as a verifier (triggers GroupKeySync bootstrap) await atlas.sendSetVerifier(atlasOwnerSender, { value: toNano('0.5'), contract: phoebe.address, isActive: true, }); // 2. ForgeTON owner admits Phoebe as a consumer (operators auto-mirror) await forgeton.sendSetConsumer(forgetonOwnerSender, { value: toNano('0.1'), contract: phoebe.address, isActive: true, }); ``` Now Phoebe is fully wired: its `groupPk` is cached, and operators staking at ForgeTON automatically mirror in. ### Tune ```typescript // Adjust the per-pull fee (consumer pays): await phoebe.sendUpdateConfig(ownerSender, { value: toNano('0.05'), pullFee: toNano('0.02'), }); // Adjust the operator share of pullFee (basis points, 0..10000). // Default at deploy is 7500 (75% operator / 25% owner). Set to 10000 for // a pure-operator economic model; 0 to drain everything to rewardPool. // Operators' previously-accrued unclaimed balances are NOT affected by // this change — `operatorBps` only governs future pulls. await phoebe.sendUpdateConfig(ownerSender, { value: toNano('0.05'), operatorBps: 8000, // 80% to operator, 20% to rewardPool }); // Out-of-range reverts E_INVALID_OPERATOR_BPS (189). // Withdraw the OWNER share of accumulated fees (only `rewardPool`, // not operator unclaimed balances — operators drain their own share via // ClaimReward; owner cannot touch it): await phoebe.sendWithdrawFees(ownerSender, { value: toNano('0.05'), to: treasuryAddr, amount: (await phoebe.getConfig()).rewardPool, // full drain }); // Re-trigger Atlas bootstrap if the initial fan-out missed: await phoebe.sendSyncAtlas(ownerSender, { value: toNano('0.1') }); // Symbol display (feed → human-readable name) lives off-chain — publish a // feeds.json file alongside your operator endpoint. There is no on-chain // `RegisterFeed` op; the verify path is feedId-registration-agnostic. ``` ### Code upgrade (3-step timelocked) ```typescript const newCode = await compile('Phoebe'); // Blueprint compile output // Step 1: propose, eta = now + 24h minimum await phoebe.sendProposeCodeUpgrade(ownerSender, { value: toNano('0.05'), newCode, delaySeconds: 86400, // 24h — wrapper computes the absolute eta }); // Step 2 (after 24h): execute await phoebe.sendExecuteCodeUpgrade(ownerSender, { value: toNano('0.05') }); // (or step 2-alt: cancel before eta) await phoebe.sendCancelCodeUpgrade(ownerSender, { value: toNano('0.05') }); ``` ### Pause / unpause (emergency) ```typescript await phoebe.sendPause(ownerSender, { value: toNano('0.05') }); // blocks PushSnapshot + RequestPrice; admin paths still work await phoebe.sendUnpause(ownerSender, { value: toNano('0.05') }); ``` --- ## Section 5 — Sandbox testing The SDK ships a `testing` subpath that stands up the real cross-contract stack in `@ton/sandbox`: ```typescript import { Blockchain } from '@ton/sandbox'; import { deployPhoebeFixture } from '@titon-network/phoebe-sdk/testing'; const blockchain = await Blockchain.create(); const fx = await deployPhoebeFixture(blockchain); // ↳ Real Atlas (via @titon-network/atlas-sdk/testing) + mock-ForgeTON // treasury + Phoebe. autoBootstrap: admits Phoebe at Atlas, mirrors // a solo operator. Ready for push + pull immediately. ``` `fx` exposes: - `phoebe` — the `SandboxContract` handle - `atlasFixture` — full Atlas fixture (rotate, admitVerifier, etc.) - `phoebeOwner`, `forgeton`, `operator` — treasuries - `groupSk`, `groupPk` — solo-mode key (32-byte sk, 48-byte pk) - `pushSnapshot({ leaves, timestamp? })` — sign + submit in one call - `requestPrice({ tree, feedId, leaf, queryId? })` — build proof + send in one call - `automatonSync({ automaton, isActive })` — mirror an arbitrary operator - `admitAtAtlas()` — re-admit (for `autoBootstrap: false` tests) - `getConfig()` — pull live tunables Example: full round-trip test: ```typescript import '@ton/test-utils'; import { OP } from '@titon-network/phoebe-sdk'; it('pushes a snapshot and pulls a feed', async () => { const fx = await deployPhoebeFixture(blockchain); const sampleLeaf = { feedId: 42, mantissa: 100n, expo: 0, confBps: 50, pubTime: 2_000_000_000, }; const { tree } = await fx.pushSnapshot({ leaves: new Map([[42, sampleLeaf]]) }); const { result } = await fx.requestPrice({ tree, feedId: 42, leaf: sampleLeaf }); expect(result.transactions).toHaveTransaction({ from: fx.phoebe.address, to: fx.phoebeOwner.address, op: OP.FulfillPrice, }); }); ``` For multi-operator scenarios, the Atlas fixture exposes `rotate()` (full timelocked rotation) and the SDK's `aggregateGroupPublicKey` / `aggregateSignatures` cover the n > 1 signing path. --- ## Section 6 — Common failure modes (cheat sheet) | Symptom | Code | Cause | Fix | |---------|------|-------|-----| | RequestPrice reverts at entry | 240 `InsufficientValue` | `msg.value < pullFee + MIN_GAS_FOR_PULL` | Use `estimatePullValue({ pullFee })` | | RequestPrice reverts post-entry | 183 `BadMerkleProof` | proof stale, leaf mismatched, or wrong feedId | Rebuild proof against the active root (cached `lastRoot` for mode A; `freshUpdate.root` for mode B); pass the same `PriceLeaf` you put in the tree | | RequestPrice reverts post-entry | 181 `StaleSnapshot` | `maxStaleness` exceeded against the active root | Set `maxStaleness=0` to accept any active root, OR wait for fresh push, OR attach a `freshUpdate` for sub-heartbeat freshness | | RequestPrice (mode B) reverts | 187 `FreshNotFresher` | `freshUpdate.timestamp <= cached lastSnapshotTime` | Query `getSnapshot()` first; if cache is at-or-newer, omit `freshUpdate` and use the cached fast-path | | RequestPrice (mode B) reverts | 161 `InvalidBlsSignature` | fresh sig doesn't verify under cached `groupPk` | Use `signMessage(operatorSk, computeSnapshotHash(phoebe.address, ts, root))` — binds DST correctly | | RequestPrice (mode B) reverts | 144 `WrongDeploymentBinding` | fresh `phoebeHash` != THIS contract's address-hash | Operator signed against a different Phoebe; recompute `signedData` against the right destination | | RequestPrice (mode B) reverts | 182 `BadTimestampDrift` | fresh `timestamp` outside `±maxPushDrift` | Use a fresh snapshot from a recent operator push; sync operator clocks (NTP) | | PushSnapshot reverts | 161 `InvalidBlsSignature` | wrong DST, drifted hash domain, stale share | Use `signMessage` (binds DST), recompute `computeSnapshotHash(phoebe.address, ts, root)` exactly | | PushSnapshot reverts | 144 `WrongDeploymentBinding` | signed payload's phoebeHash != destination contract's hash (cross-deployment replay defense) | Pass the destination Phoebe's `Address` to `computeSnapshotHash`; ensure the operator's signing pipeline points at the right Phoebe address | | PushSnapshot reverts | 181 `StaleSnapshot` | `timestamp <= storage.lastSnapshotTime` | Use a strictly newer timestamp | | PushSnapshot reverts | 182 `BadTimestampDrift` | `|now - ts| > maxPushDrift` | Sync operator clock (NTP); raise `maxPushDrift` if network-jitter dominates | | PushSnapshot reverts | 140 `OperatorNotFound` | not mirrored from ForgeTON | Stake at ForgeTON; ensure Phoebe is admitted as a consumer; or `sendSyncAtlas()` | | PushSnapshot reverts | 141 `OperatorNotActive` | mirrored but `isActive=false` | Re-stake at ForgeTON; AutomatonSync re-mirrors | | PushSnapshot reverts | 180 `GroupKeyNotSet` | Atlas hasn't bootstrapped yet | Owner: `phoebe.sendSyncAtlas(...)` | | GroupKeySync reverts | 130 `GroupIdNotSupported` | Atlas sent `groupId != 0` | Atlas misconfigured — debug at the Atlas side | | GroupKeySync reverts | 142 `GroupEpochNotMonotonic` | replay of older epoch | Non-actionable — cache is already on the newer epoch | | GroupKeySync reverts | 143 `GroupKeyChangedAtEpoch` | same-epoch republish with different fields | Atlas force-sync should re-push the EXACT cached state; if it didn't, debug Atlas | | AutomatonSync reverts | 200 `NotForgeton` | sender ≠ `storage.forgeton` | Phoebe is pinned at deploy; needs code upgrade to repoint | | GroupKeySync reverts | 201 `NotAtlas` | sender ≠ `storage.atlas` | Same — code upgrade to repoint | | Owner op reverts | 100 `NotOwner` | sender ≠ `storage.owner` | Use the right wallet; check `getOwnership()` | | Owner op reverts | 101 `OperationPaused` | only PushSnapshot + RequestPrice are pause-blocked | Owner ops should always work under pause; if not, file a bug | | ProposeCodeUpgrade reverts | 225 `UpgradeEtaTooSoon` | `eta < now + 24h` | Pass `delaySeconds: 86400+` | | ExecuteCodeUpgrade reverts | 226 `UpgradeNotReady` | `now < eta` | Wait for the timelock | | ClaimReward reverts | 140 `OperatorNotFound` | sender not in operator-mirror map | Stake at ForgeTON; ensure Phoebe is admitted as consumer; check `getOperator(addr)` | | ClaimReward reverts | 188 `NoUnclaimedReward` | balance is zero (never accrued, already drained, or no pull has landed against your push yet) | Query `getUnclaimedReward(addr)` first; the accrual happens at pull-time, not push-time | | ClaimReward reverts | 242 `AmountExceedsAccumulated` | non-zero `amount` exceeds current balance | Use `amount: 0n` for claim-all, or pass `await phoebe.getUnclaimedReward(addr)` as the exact value | | ClaimReward reverts | 243 `BadWithdrawTarget` | `to` is the Phoebe contract's own address (would create phantom funds) | Pick any other address (operator wallet, treasury, cold storage) | | ClaimReward reverts | 240 `InsufficientValue` | `value < MIN_GAS_FOR_CLAIM` (0.03 TON) | Attach `toNano('0.05')` or more for slack | | UpdateConfig reverts | 189 `InvalidOperatorBps` | `operatorBps > 10000` | Valid range is `[0, 10000]`; 10000 = 100% to operator, 0 = 100% to rewardPool | | UpdateConfig reverts | 190 `InvalidMaxInactivity` | `maxInactivity > 86400` (24h cap) | Valid range is `[0, 86400]`; 0 = disabled (default), recommended 5–10× heartbeat | | Tx reverts with 0xFFFF | 65535 `UnknownOpcode` | sent a body with an opcode not in the AllowedMessage union | Check `OP` table; empty body works for top-up | Always reach for `explainError(code)` first; the SDK has the full table with hints. --- ## Section 7 — What's NOT in scope Explicitly out of scope (intentional design choices, not deferred work): - **Bad-data slashing** — Phoebe is carrot-only; operators self-opt-out by not pushing. Bad-data detection is governance-subjective; even Pyth's slashing is governance-gated. Future candidate (optimistic dispute window or DEX-TWAP cross-check). - **Atlas auto-rotation subscription** — manual `sendSyncAtlas()` for now; a future enhancement could subscribe to Atlas's rotation events and update automatically. - **First-party publisher mode** — Pyth-style direct-publish. Future candidate; requires per-publisher on-chain footprint. - **Cross-chain delivery** — Phoebe state-proof to Ethereum / Solana via a future titon bridge. - **Multi-shard Phoebe instances** — single instance today, 256 dense leaves. The standard timelocked code upgrade can grow the tree to 512+ leaves (new code lifts `MAX_FEED_COUNT` + `TREE_DEPTH` and consumer wrappers update in lockstep); multi-shard not until > 1000 feeds saturate one tree. - **Pruned-branch proofs** — `tree.proof()` already returns a TVM merkle_proof exotic cell wrapping a pruned tree (~417 B per pull, ~20× smaller than the legacy full tree). The on-chain `verifyAndUnwrapProof` discriminates via `XCTOS`'s `isSpecial` flag and accepts both shapes. Use `tree.fullTreeProof()` for the legacy ~8.5 KB form. --- ## Section 8 — Pointers - [Phoebe contract source](../contracts/phoebe.tolk) - [On-chain ABI](../contracts/messages.tolk) - [Error table source](../contracts/errors.tolk) - [PLAN.md](../PLAN.md) — implementation phases (status: Phase 0/1/2/4 done; SDK published in Phase 2.5; testnet deploy in Phase 5b) - Sibling SDKs: - [`@titon-network/atlas-sdk`](https://github.com/titon-network/atlas) — group-key backbone - [`@titon-network/forgeton-sdk`](https://github.com/titon-network/forgeton) — staking pool - [`@titon-network/fortuna-sdk`](https://github.com/titon-network/fortuna) — sibling VRF verifier consumer; same architectural pattern as Phoebe - [llmstxt.org](https://llmstxt.org) — the convention this file follows