# @titon-network/phoebe-sdk > TypeScript SDK for Phoebe — TON's price oracle. Operators threshold-BLS-sign 256-feed Merkle snapshots; consumers pull individual feeds via cell-merkle proofs in one of two modes: (A) cached fast-path (read `lastRoot`, ~13k gas) for routine reads, OR (B) fresh-update path (attach a freshly signed snapshot inline, ~73k gas, Pyth-style) for sub-heartbeat freshness. Verifier consumer of Atlas (group key) + ForgeTON (operator stake). Built on TSA-audited @titon-network/atlas-sdk + @titon-network/forgeton-sdk. This file follows the [llmstxt.org](https://llmstxt.org) convention. AI assistants reading it should load the linked files for complete context before generating code that talks to Phoebe. All paths are relative to the installed package (`node_modules/@titon-network/phoebe-sdk/`); if you cloned the repo, prefix with `sdks/typescript/`. ## Start here - [README.md](./README.md): consumer / operator / owner integration playbook — install, scaffold, push snapshots, pull prices, decode events. - [AGENTS.md](./AGENTS.md): full surface map — types, events, skills index, opcode + error ranges. The navigator for more detailed work. - [GUARANTEES.md](./GUARANTEES.md): what the SDK guarantees vs. what the consumer is responsible for. Read once. ## Core opcodes - `OP.PushSnapshot = 0x70` — operator → Phoebe (BLS-attested snapshot) - `OP.RequestPrice = 0x71` — consumer → Phoebe (Merkle-proofed pull, fee-gated). Optional `freshUpdateRef: Cell?` — when set, Phoebe BLS-verifies + advances cache before walking the proof against the fresh root. - `OP.FulfillPrice = 0x72` — Phoebe → consumer (verified PriceLeaf callback; `bounce: false`) - `OP.GroupKeySync = 0x51` — Atlas → Phoebe (cache update) - `OP.AutomatonSync = 0x1A` — ForgeTON → Phoebe (operator state mirror; bytewise-identical across repos) - `OP.SyncAtlas = 0xA4` — owner → Phoebe → SyncRequest (0x50) → Atlas (re-bootstrap) - `OP.ClaimReward = 0xA8` — operator → Phoebe (claim accrued pull-fee share). Sender must be in operator map (active OR inactive). `amount: 0n` = claim entire balance. Route to any `to` address. - `OP.EvtRewardClaimed = 0x8C` — emitted on successful claim; payload `(claimant, to, amount)`. - BLS ciphersuite: min-pk, G1 pubkeys 48B, G2 sigs 96B, DST `BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_` (exported as `BLS_DST_G2_POP`) - Single-group: `groupId` is always 0 (Atlas's constraint; Phoebe inherits) - Dense Merkle tree: `MAX_FEED_COUNT = 256`, `TREE_DEPTH = 8`. Bumping ships via the standard timelocked code upgrade (`Propose` → 24h → `Execute`); new code lifts both constants and consumer wrappers update in lockstep. - Snapshots are monotonic + drift-bounded (`maxPushDrift`, default 300s) ## Operator economics + liveness filter - Every accepted `RequestPrice` splits `pullFee` two ways: `pullFee * operatorBps / 10000` credits the **most-recent `PushSnapshot` submitter** (`lastSubmitter`); the remainder lands in `rewardPool` (owner-withdrawable via `WithdrawFees`). - Default `operatorBps = 7500` (75% operator / 25% owner). Owner-tunable via `UpdateConfig.operatorBps?` (0..10000); invalid values revert `InvalidOperatorBps` (189). - `lastSubmitter` is mutated ONLY by `PushSnapshot` (mode A). Consumer-attached `freshUpdate` (mode B) advances the cache but does NOT change `lastSubmitter` — consumers cannot pay themselves the operator share. - **`maxInactivity` pull-side filter:** when `maxInactivity > 0`, if the `lastSubmitter`'s `lastPushAt` is older than `now - maxInactivity`, the operator share rolls into `rewardPool` instead of accruing to the silent op. Owner-tunable via `UpdateConfig.maxInactivity?` (0..86400; 0 = disabled, default; recommended 5–10× heartbeat). Invalid values revert `InvalidMaxInactivity` (190). - Pre-first-push grace: when no operator has ever pushed (`lastSubmitter == null`) OR the silent-op filter drops the slot, pulls accrue 100% to `rewardPool`. - `phoebe.getUnclaimedReward(operatorAddress)` returns the operator's current unclaimed balance (0n if never accrued or fully claimed). - `phoebe.getLastSubmitter()` returns the most-recent `PushSnapshot` submitter (`Address | null`). - `phoebe.sendClaimReward(operator, { to, amount })` — `amount: 0n` claims the entire balance and deletes the map entry; non-zero claims exact amount (reverts `AmountExceedsAccumulated` (242) if over-claim). Reverts `NoUnclaimedReward` (188) on zero balance. - Inactive operators can still claim — earned-is-earned. ForgeTON deactivation doesn't zero accrued balances. - **Phoebe is carrot-only.** No `Slash` outbound from Phoebe — operators self-opt-out by simply not pushing. ## Canonical signing pre-image Compute the snapshot signing input off-chain byte-identically to the Tolk contract: ```typescript import { computeSnapshotHash, signMessage } from '@titon-network/phoebe-sdk'; const sigInput = computeSnapshotHash(phoebe.address, timestamp, root); // sigInput = be256(phoebeHash) || be32(timestamp) || be256(root) (68 bytes) // — phoebeHash binds the sig to ONE Phoebe deployment (replay defense) const sig = signMessage(operatorSk, sigInput); // BLS12-381 min-pk sign; DST = BLS_DST_G2_POP ``` ## Drop-in references - [consumer.tolk](./templates/consumer.tolk) — minimal Tolk Phoebe consumer: receives `FulfillPrice` at 0x72, sends `RequestPrice` at 0x71. The canonical starting point for any price-consuming product. - Scaffold via CLI: `npx phoebe scaffold consumer` - [examples/price-aware-vault/](./examples/price-aware-vault) — full price-quoted withdrawal vault example (Tolk + TS wrapper). ## FulfillPrice receiver safety (apply every time) 1. `assert sender == storage.phoebe` — prevents a spoofed callback injecting fake prices into your contract. 2. Correlate via `queryId` — Phoebe echoes the consumer-supplied `queryId`; use it to look up pending requests. 3. **Don't throw in `handleFulfillPrice` unless state demands it** — Phoebe sends with `bounce: false`; a revert does NOT refund gas and the contract has already retained the pullFee. Design callbacks to be revert-proof. 4. Trust `leaf.mantissa`, `leaf.expo`, `leaf.confBps`, `leaf.pubTime` — these are cryptographically validated by the BLS aggregate signature. Don't second-guess them in the receiver. ## Error debugging - [ERRORS.md](./ERRORS.md): flat table, every Phoebe + TVM code with message + hint - Runtime: `import { explainError } from '@titon-network/phoebe-sdk'; explainError(exitCode)` → `{ code, origin, name, message, hint }` - CLI: `npx phoebe explain ` ## Opcode reference - [OPCODES.md](./OPCODES.md): flat table, every wire struct with hex opcode - Runtime: `import { OP } from '@titon-network/phoebe-sdk'` ## Off-chain BLS helpers (operator side) - `import { signMessage, aggregateSignatures, computeSnapshotHash } from '@titon-network/phoebe-sdk'` - `signMessage(sk, sigInput)` wraps `@noble/curves/bls12-381` `longSignatures.sign` with `BLS_DST_G2_POP` bound in — the default DST (`_NUL_`) silently produces sigs that fail TVM's `BLS_VERIFY`. - `aggregateSignatures(partials)` is the off-chain aggregator's last step — combine t-of-n partials into a single 96-byte G2 sig. - Solo-mode (n=1) shortcut: `signMessage(sk, computeSnapshotHash(phoebe.address, ts, root))` — no aggregation needed; the resulting sig verifies against `groupPk == blsPublicKey(sk)`. The `phoebe.address` first argument binds the signature to that specific Phoebe deployment (cross-deployment replay defense — see `SignedSnapshot` in messages.tolk). ## Off-chain Merkle tree (consumer + operator side) - `import { PhoebeMerkleTree } from '@titon-network/phoebe-sdk'` - `PhoebeMerkleTree.fromSparseLeaves(leaves)` — build a 256-leaf tree from a sparse `Map`; empty slots get the canonical placeholder. - `tree.rootAsBigint()` — 32-byte root the operator threshold-signs. - `tree.proof(feedId)` — pruned-branch merkle_proof exotic cell (~417 B). Its stored hash equals `tree.rootAsBigint()`. The on-chain `verifyAndUnwrapProof` reads the stored hash via `XCTOS` and binds it against `lastRoot`. Use `tree.fullTreeProof(feedId)` for the legacy full-tree shape (~8.5 KB). ## Pull-value estimation ```typescript import { estimatePullValue, PHOEBE_DEFAULTS } from '@titon-network/phoebe-sdk'; // Cached fast-path const value = estimatePullValue({ pullFee: PHOEBE_DEFAULTS.pullFee }); // = pullFee + MIN_GAS_FOR_PULL (0.03 TON) + 0.05 TON forward-fee slack // Fresh-update path (sub-heartbeat freshness) const valueFresh = estimatePullValue({ pullFee: PHOEBE_DEFAULTS.pullFee, withFresh: true }); // = above + 0.05 TON BLS_VERIFY budget ``` For live contracts, always read `phoebe.getConfig()` — tunables drift from defaults via `UpdateConfig`. ## Fresh-update path (Pyth-style update + read) When 30s heartbeat freshness isn't enough (lending liquidations, perps), attach a freshly signed snapshot inline with `RequestPrice`: ```typescript import { computeSnapshotHash, signMessage, estimatePullValue } from '@titon-network/phoebe-sdk'; // Query operator's signed-data feed for a fresher snapshot const fresh = await operatorClient.getSignedSnapshot(); // { timestamp, root, sig } // Sanity-check it's actually newer than what's on-chain (avoids E_FRESH_NOT_FRESHER) const { lastSnapshotTime } = await phoebe.getSnapshot(); if (fresh.timestamp <= lastSnapshotTime) { // Cache is already at-or-newer; just use the cached fast-path. await phoebe.sendRequestPrice(via, { value: estimatePullValue({ pullFee }), ... }); return; } // Otherwise attach the fresh update const signedData = computeSnapshotHash(phoebe.address, fresh.timestamp, fresh.root); await phoebe.sendRequestPrice(via, { value: estimatePullValue({ pullFee, withFresh: true }), queryId: 1n, feedId: 42, proof: freshTree.proof(42), // proof against fresh.root leaf: claimedLeaf, maxStaleness: 0, freshUpdate: { signedData, sig: fresh.sig }, }); // Phoebe BLS-verifies + advances cache + walks proof + emits BOTH // EvtSnapshotPushed (submitter = consumer) AND EvtPricePulled. ``` Permissionless caller — the BLS aggregate under Atlas's `groupPk` is the auth. Cache advances atomically before the Merkle walk, so subsequent same-block pulls read the advanced cache for free. ## Push-value estimation ```typescript import { estimatePushValue } from '@titon-network/phoebe-sdk'; const value = estimatePushValue(); // = MIN_GAS_FOR_PUSH (0.05 TON) + 0.05 TON forward-fee slack // Excess refunded via SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE. ``` CLI: `npx phoebe estimate pull --pull-fee 0.01` / `npx phoebe estimate push`. ## Sandbox testing - `import { deployPhoebeFixture } from '@titon-network/phoebe-sdk/testing'` — spins up Phoebe + real Atlas (via `@titon-network/atlas-sdk/testing`) + mock-ForgeTON inside `@ton/sandbox` in one line. Returns `{ phoebe, atlasFixture, operator, groupSk, groupPk, pushSnapshot, requestPrice, automatonSync, ... }`. - The `pushSnapshot({ leaves })` helper signs + submits the snapshot in one call (no manual BLS handling). - The `requestPrice({ tree, feedId, leaf })` helper builds the proof + sends `RequestPrice` in one call. - For multi-operator scenarios, see the Atlas SDK's `simulateDkg` (Phoebe inherits Atlas's group-key shape). ## Task-shaped playbooks Five persona-grouped skill files. Use `/skill-name` to invoke from a Claude Code-style assistant. **Consumer:** - [skills/phoebe-integrate-consumer.md](./skills/phoebe-integrate-consumer.md): write a Tolk consumer that pulls prices **Operator:** - [skills/phoebe-operator-setup.md](./skills/phoebe-operator-setup.md): stake at ForgeTON, register pkShare at Atlas, run the off-chain pusher **Owner:** - [skills/phoebe-owner-ops.md](./skills/phoebe-owner-ops.md): pause / config / fees / upgrade / recovery - [skills/phoebe-deploy.md](./skills/phoebe-deploy.md): deploy Phoebe, bind to Atlas, admit at ForgeTON **Debugging:** - [skills/phoebe-debug.md](./skills/phoebe-debug.md): exit codes, event trace, schema drift ## CLI — one-off introspection - `phoebe explain ` — describe an exit code - `phoebe decode ` — decode an external-out event cell - `phoebe hash` — bundled artifact code hash - `phoebe schema` — SDK-expected schema versions + BLS suite - `phoebe estimate pull` / `phoebe estimate push` — recommended msg.value - `phoebe scaffold consumer` — scaffold a Tolk Phoebe consumer template - `phoebe init` — write `PHOEBE.md` (agent context) into the current repo - `phoebe deployments` — list known live Phoebe addresses + status - `phoebe info ` — pretty-print live Phoebe state (needs `@ton/ton`) - `phoebe verify --testnet|--mainnet` — drift check against a known deploy - All commands accept `--json` for machine-readable output ## What NOT to do - Don't edit the `RequestPrice` or `FulfillPrice` wire struct field order in your consumer — must be bytewise-identical to `phoebe/contracts/messages.tolk`. Scaffold via `npx phoebe scaffold consumer`. - Don't use `@noble/curves`'s default DST (`_NUL_`) — it silently produces sigs that fail on-chain `BLS_VERIFY`; always pass `BLS_DST_G2_POP` (or use `signMessage(sk, msg)`). - Don't push snapshots whose timestamp is `<= storage.lastSnapshotTime` OR outside `±maxPushDrift` of `now` — `E_STALE_SNAPSHOT` (181) / `E_BAD_TIMESTAMP_DRIFT` (182). Operators must keep clocks in sync. - Don't attach a `freshUpdate` whose timestamp is `<=` the cached `lastSnapshotTime` — `E_FRESH_NOT_FRESHER` (187). Query `getSnapshot()` first; if cache is already at-or-newer, just use the cached fast-path (omit `freshUpdate`). - Don't call `RequestPrice` with a `leaf` that doesn't match the leaf in your tree — `E_BAD_MERKLE_PROOF` (183). Use the same `PriceLeaf` you put in the tree at `feedId`. - Don't attach less than `pullFee + MIN_GAS_FOR_PULL` (0.03 TON) — receiver reverts with `E_INSUFFICIENT_VALUE` (240). Add slack for forward-fee deduction (~0.05 TON). - Don't expect callback bounces on consumer-side reverts — Phoebe sends with `bounce: false` specifically so a bad consumer can't lock Phoebe's fee accounting. Design callbacks to be revert-proof. - Don't hand-decode events when `decodeEvent(body)` exists — the field widths aren't obvious (coins is varuint, addresses have variable cell structure, etc.). - Don't skip the `assert (sender == storage.phoebe)` check in your `handleFulfillPrice` — a missing pin = anyone can deliver fake prices to your contract by sending a 0x72 body. - Don't reuse a Merkle proof across snapshots — each `lastRoot` rotation invalidates all prior proofs. Build a fresh proof against the current snapshot before every pull.