# AGENTS.md — @titon-network/phoebe-sdk

> AI assistants reading this: this is your map of `@titon-network/phoebe-sdk`. Skim the **Quick reference**, then jump to the section the user is asking about.

`@titon-network/phoebe-sdk` is the TypeScript client for [**Phoebe**](../) — TON's price oracle:

- **Operators** stake at ForgeTON + register a pkShare at Atlas. They aggregate prices off-chain into a 256-feed Merkle tree, threshold-BLS-sign `(timestamp, root)`, and push to Phoebe every ~30s. Phoebe verifies the aggregate against Atlas's cached `groupPk` and caches the new `(lastRoot, lastSnapshotTime)`.
- **Consumers** (any TON dapp) build a Merkle proof for the feed they want, attach `pullFee + gas`, and call `RequestPrice`. Phoebe walks the proof against `lastRoot`, asserts the leaf's hash matches what the proof commits to, and sends `FulfillPrice { queryId, leaf }` back to the consumer at opcode `0x72`.

Phoebe **binds to two upstream contracts**: ForgeTON (operator state via `AutomatonSync` at `0x1A`) and Atlas (group key via `GroupKeySync` at `0x51`). It never slashes — operators self-opt-out by simply not pushing.

Small surface by design — ABI wrapper, event decoder, error table, BLS primitives, Merkle tree builder, diagnostics, bundled artifact, sandbox fixture.

## Quick reference

| User wants to… | Use this |
|----------------|----------|
| Deploy a fresh Phoebe | `newPhoebe({ owner, forgeton, atlas })` → `sendDeploy(via, toNano('1'))` |
| Admit Phoebe at Atlas (from Atlas owner) | `atlas.sendSetVerifier(ownerVia, { value, contract: phoebe.address, isActive: true })` — triggers bootstrap GroupKeySync |
| Admit Phoebe at ForgeTON (from ForgeTON owner) | `forgeton.sendSetConsumer(ownerVia, { value, contract: phoebe.address, isActive: true })` — operators auto-mirror via AutomatonSync |
| Operator pushes a snapshot | `phoebe.sendPushSnapshot(operatorVia, { value, timestamp, root, aggSig })` — `value` ≥ `MIN_GAS_FOR_PUSH` (0.05 TON); use `estimatePushValue()` |
| Build a Merkle proof off-chain | `PhoebeMerkleTree.fromSparseLeaves(leaves)` → `tree.proof(feedId)` |
| Build the snapshot signing input | `computeSnapshotHash(phoebe.address, timestamp, root)` — byte-identical to Tolk; address-hash binds the sig to one Phoebe deployment |
| Operator signs the snapshot bytes | `signMessage(operatorSk, sigInput)` — min-pk, DST `BLS_DST_G2_POP` |
| Aggregate threshold signatures | `aggregateSignatures([sig1, sig2, …])` |
| Consumer sends a pull (mode A — cached fast-path, ~13k gas) | `phoebe.sendRequestPrice(via, { value, queryId, feedId, proof, leaf, maxStaleness: 0 })` — `value` ≥ `pullFee + MIN_GAS_FOR_PULL`; use `estimatePullValue({ pullFee })` |
| Consumer sends a pull (mode B — Pyth-style update + read, ~73k gas) | `phoebe.sendRequestPrice(via, { ..., freshUpdate: { signedData, sig } })` — attach a freshly signed snapshot from any operator's `/signed-snapshot` endpoint; `value` ≥ `pullFee + MIN_GAS_FOR_PULL + 0.05 TON` for the BLS_VERIFY budget; use `estimatePullValue({ pullFee, withFresh: true })`. Consumer pays for sub-heartbeat freshness; cache advances atomically (positive externality). |
| Build the fresh-update payload off-chain | `signedData = computeSnapshotHash(phoebe.address, fresh.timestamp, fresh.root)`; `sig` = the operator's published 96-byte aggregate sig over `signedData`. Optional helper: `buildFreshUpdateCell({ signedData, sig })` for raw body construction. |
| Decode a FulfillPrice callback body in the consumer | `parseFulfillPrice(body)` → `{ queryId, leaf }` |
| Owner withdraws accumulated fees | `phoebe.sendWithdrawFees(ownerVia, { value, to, amount })` — `amount ≤ rewardPool` |
| Owner re-triggers Atlas bootstrap | `phoebe.sendSyncAtlas(ownerVia, { value })` |
| Owner upgrades the contract | 3-step: `sendProposeCodeUpgrade({ newCode, delaySeconds: 86400 })` → wait → `sendExecuteCodeUpgrade` |
| Decode emitted events | `decodeEvents(externalOutBodies)` → typed `PhoebeEvent` union, switch on `ev.kind` |
| Understand an exit code | `explainError(161)` → `{ origin, name, message, hint }` |
| Collapse a tx for logging | `summarizeTx(tx)` / `formatTxSummary(summary)` |
| Spin up a sandbox test | `deployPhoebeFixture(blockchain)` from `@titon-network/phoebe-sdk/testing` |
| Implement a Phoebe consumer contract | Copy [`templates/consumer.tolk`](./templates/consumer.tolk) — receiver at `0x72`, outbound at `0x71` |

## Public surface (`src/index.ts`)

**Contracts:** `Phoebe`, `PhoebeMerkleTree`, `PHOEBE_DEFAULTS`, `MIN_RESERVE_FLOOR`, `MIN_UPGRADE_DELAY`, `DEFAULT_MAX_PUSH_DRIFT`, `phoebeConfigToCell`, `priceLeafToCell`, `priceLeafFromSlice`, `parseFulfillPrice`, `buildFreshUpdateCell`, and all the `Send*Opts` + reply types (`PhoebeConfigReply`, `SchemaVersionsReply`, `OperatorReply`, `GroupKeyReply`, `SnapshotReply`, `PendingUpgradeReply`, `OwnershipReply`, `PriceLeaf`, `FreshUpdateOpts`).

**Factory:** `newPhoebe({ owner, forgeton, atlas, workchain? })`.

**BLS primitives:** `randomBlsSecret`, `blsPublicKey`, `aggregateGroupPublicKey`, `signMessage`, `aggregateSignatures`, `computeSnapshotHash`.

**Events:** `PhoebeEvent` union + 11 typed event interfaces (`OperatorMirrored`, `GroupKeyCached`, `SnapshotPushed`, `PricePulled`, `Paused`, `Unpaused`, `ConfigUpdated`, `FeesWithdrawn`, `CodeUpgradeProposed`, `CodeUpgradeExecuted`, `CodeUpgradeCancelled`). Decoders: `decodeEvent`, `decodeEvents`, `tryDecodeEvent`. Cause constants: `CAUSE_FIRST_SYNC`, `CAUSE_ACTIVATION_CHANGE`.

**Errors:** `explainError`, `PhoebeError`, types `ErrorCode` / `ErrorOrigin` / `ErrorExplanation`.

**Diagnostics:** `summarizeTx`, `summarizeTxs`, `formatTxSummary`, `QueryIdStream`, `estimatePullValue`, `estimatePushValue`, type `TxSummary`.

**Constants:** `OP`, `ERR`, schema versions (`PHOEBE_STORAGE_VERSION`, `PHOEBE_CONFIG_BLOB_VERSION`), BLS (`BLS_PUBKEY_BYTES`, `BLS_SIG_BYTES`, `BLS_PUBKEY_BITS`, `BLS_SIG_BITS`, `BLS_DST_G2_POP`), protocol (`DEFAULT_GROUP_ID`, `MIN_UPGRADE_DELAY_SECONDS`, `MAX_FEED_COUNT = 256`, `TREE_DEPTH = 8`, `MAX_MAX_INACTIVITY_SECONDS = 86400`).

**Artifact:** `loadPhoebeCode`, `loadPriceConsumerCode`, `phoebeCodeHash`, `priceConsumerCodeHash`, `PHOEBE_CODE_HASH`, type `CompiledArtifact`.

**Deployments:** `PHOEBE_TESTNET` / `PHOEBE_MAINNET` (populated post-deploy; `PHOEBE_MAINNET.operators` ships the live operator HTTP endpoints), `PHOEBE_EXPECTED_SCHEMA`, `assertDeployment`, type `PhoebeDeployment`.

**Consumer-side price read (no-operator-needed):** `fetchVerifiedPrice(phoebe, feedId, operators, opts?)` — fetches leaves from any operator over HTTP, reconstructs the merkle root locally, compares to `phoebe.lastRoot` on-chain, returns a `VerifiedPriceQuote` with `{leaf, proof, mantissa, expo, confBps, pubTime, ageSec, ...}` ready to submit to a consumer contract. Self-verifying: stale / buggy operators caught by hash mismatch + the helper falls through to the next in the list. Types: `OperatorEndpoint`, `VerifiedPriceQuote`, `FetchVerifiedPriceOptions`. Skill: [`skills/phoebe-consume-price.md`](skills/phoebe-consume-price.md).

**Testing:** `deployPhoebeFixture(blockchain, opts?)` from `@titon-network/phoebe-sdk/testing` — deploys Phoebe + real Atlas (via `@titon-network/atlas-sdk/testing`) + mock-ForgeTON in one call, with `pushSnapshot` / `requestPrice` / `automatonSync` / `admitAtAtlas` / `getConfig` helpers.

## Phoebe in one page

Phoebe is a **price oracle**, not a key backbone or staking pool. It binds to:

1. **Atlas owns the group key.** Atlas publishes `groupPk` + `groupEpoch`, fans out to subscribed verifiers via `GroupKeySync` (0x51). Phoebe's receiver at `0x51` caches `(groupPk, groupEpoch, threshold, memberCount)` and runs a defensive `BLS_G1_INGROUP` on the cached key.
2. **ForgeTON owns the operator set.** ForgeTON fans out `AutomatonSync` (0x1A) to Phoebe on operator activation / deactivation. Phoebe mirrors `(isActive)` per operator.
3. **Phoebe owns snapshot + Merkle verify, with two pull modes.** Operators sign `(timestamp, root)` off-chain (where `root` is the Merkle root over a 256-leaf tree, one leaf per feed); the first valid push wins. Consumers can either (A) **read the cached root** with a Merkle proof against `lastRoot` (cheap fast-path, ~13k gas) — the routine case; or (B) **attach a fresh signed snapshot** (`freshUpdate`) inline with their pull (~73k gas, Pyth-style). Mode B BLS-verifies the attached snapshot, advances the cache (positive externality — subsequent same-block pulls read the advanced cache for free), and walks the proof against the fresh root. Both modes call back via `FulfillPrice (0x72)`. Symbol display lives in operator-published off-chain config files (no on-chain feed registry — the verify path is feedId-registration-agnostic).

```
                    ┌─────────────────────────────────┐
   off-chain         │  Operator (Automaton)            │
                    │  ├─ price aggregator             │
                    │  ├─ Merkle tree builder           │
                    │  ├─ BLS partial-sig (Atlas key)   │
                    │  └─ submission cadence ~30s       │
                    └────────────────┬────────────────┘
                                     │ PushSnapshot (0x70)
                                     │ — internal msg, BLS-attested
                                     ▼
   on-chain           ┌──────────────────────────┐
                    │  Phoebe                  │ ← Atlas (0x51 fan-out)
                    │  ├─ groupPk (cached)     │
                    │  ├─ lastRoot, lastTime   │ ← ForgeTON (0x1A fan-out)
                    │  └─ rewardPool           │
                    └────┬─────────────────────┘
                         │ FulfillPrice (0x72)
                         │ — bounce: false
                         ▼
                    ┌──────────────────────┐
                    │  Consumer (any TON   │
                    │  contract; 0x72 rcvr) │
                    └──────────────────────┘
```

## Writing code with this SDK

**Default to the typed wrapper.** `Phoebe` has every send/get you need. Don't hand-build opcodes unless implementing a test that specifically probes the dispatcher.

**Use `estimatePullValue` / `estimatePushValue` for sizing.** Under-fee → `E_INSUFFICIENT_VALUE` (240). The estimators bake in `MIN_GAS_FOR_PULL` (0.03 TON) / `MIN_GAS_FOR_PUSH` (0.05 TON) + a 0.05 TON forward-fee slack so first-attempts don't bounce.

**Solo-mode signing (for dev / demo):** `signMessage(sk, computeSnapshotHash(phoebe.address, ts, root))` wraps `@noble/curves/bls12-381` `longSignatures.sign` with `BLS_DST_G2_POP` bound in. The default DST is `_NUL_` and silently produces sigs that fail TVM's `BLS_VERIFY` — pin `signMessage` from this SDK every time. The `phoebe.address` first argument is non-optional: its hash gets prefixed into the signed bytes for cross-deployment replay defense (a sig produced for Phoebe A will fail at Phoebe B with `E_WRONG_DEPLOYMENT_BINDING` (144) before BLS verify even runs).

**Use `QueryIdStream` for unique queryIds across concurrent pulls.** Phoebe doesn't have a duplicate-request guard like Fortuna does, but consumers indexing callbacks by queryId still benefit from monotone-unique IDs. `new QueryIdStream().next()` returns monotone `bigint`s starting from 1.

**Decode events immediately after a send.** `summarizeTx(tx).events` gives you the typed union; switch on `kind`. The event opcode is the authoritative source for what actually happened.

**Phoebe consumers: copy the template.** [`templates/consumer.tolk`](./templates/consumer.tolk) has the receiver-safety banner + wire-shape discipline baked in. Use `npx phoebe scaffold consumer` to drop it into your repo.

**Off-chain Merkle proofs:** `tree.proof(feedId)` returns a TVM merkle_proof exotic cell wrapping a pruned-branch tree — **~417 bytes per pull** (~20× smaller than the full tree). The on-chain `verifyAndUnwrapProof` reads the wrapper's stored hash via the `XCTOS` opcode for the root-binding check. Legacy `tree.fullTreeProof(feedId)` (~8.5 KB) still works for callers that can't construct exotic cells.

**The leaf you claim MUST be the leaf you put in the tree.** `RequestPrice` asserts `leaf.feedId == msg.feedId` AND `walkMerkleProof(proof, feedId) == leaf.toCell().hash()`. If either fails → `E_BAD_MERKLE_PROOF` (183). Always pass the same `PriceLeaf` you used to build the tree.

**Snapshot pushes are monotonic + drift-bounded.** `timestamp > storage.lastSnapshotTime` AND `|now - timestamp| < maxPushDrift` (default 300s). Stale or future-dated → `E_STALE_SNAPSHOT` (181) / `E_BAD_TIMESTAMP_DRIFT` (182). Operators must keep their clock in sync (NTP).

## Liveness — `maxInactivity` pull-side filter (opt-in via `UpdateConfig`)

Phoebe ships one owner-tunable liveness lever, disabled by default.

- **`maxInactivity`** — pull-side filter on the operator-share split. When `maxInactivity > 0` AND the most-recent pusher's `lastPushAt` is older than `now - maxInactivity`, the operator share rolls into `rewardPool` instead of accruing to the silent operator. Consumers literally cannot pay an operator who has stopped working. Set 5–10× the heartbeat interval. 0 = disabled (default; silent `lastSubmitter` still receives the operator share). Adds ~1.5k gas to `RequestPrice`.
- **No Phoebe-side slashing.** Phoebe stays carrot-only — operators self-opt-out by simply not pushing. There is no `Slash` outbound from Phoebe.

## Opcode ranges (reference)

| Range | Purpose |
|-------|---------|
| `0x1A` | `AutomatonSync` — inbound from ForgeTON, bytewise-identical to `forgeton/contracts/messages.tolk` |
| `0x50` | `SyncRequest` — outbound to Atlas (owner-triggered re-bootstrap) |
| `0x51` | `GroupKeySync` — inbound from Atlas, bytewise-identical to `atlas/contracts/messages.tolk` |
| `0x70 – 0x7F` | Phoebe-specific (`PushSnapshot 0x70`, `RequestPrice 0x71`, `FulfillPrice 0x72`; 0x73-0x7F reserved) |
| `0x80 – 0x8C` | Phoebe events (incl. `EvtRewardClaimed 0x8C`) |
| `0xA0 – 0xA8` | Owner admin + operator claim (pause, config, withdraw, sync-atlas, propose/execute/cancel upgrade, `ClaimReward 0xA8`) |

Full table: [`OPCODES.md`](./OPCODES.md).

## Error-code ranges (reference)

| Range | Section |
|-------|---------|
| 100 – 109 | Access control / lifecycle (NotOwner, OperationPaused, NotPaused, AlreadyPaused) |
| 110 – 119 | Schema / version (BadSchemaVersion) |
| 130 – 131 | Group / groupId (GroupIdNotSupported, InvalidThresholdConfig — single-group guards) |
| 140 – 149 | Operator / submission + group-key monotonicity (OperatorNotFound, OperatorNotActive, GroupEpochNotMonotonic, GroupKeyChangedAtEpoch) |
| 160 – 169 | BLS / crypto (InvalidBlsSignature, InvalidG1Point) |
| 180 – 199 | Phoebe-specific — snapshot + Merkle proof + operator-claim + liveness (GroupKeyNotSet, StaleSnapshot, BadTimestampDrift, BadMerkleProof, FeedIdOutOfRange, FreshNotFresher, NoUnclaimedReward, InvalidOperatorBps, InvalidMaxInactivity) |
| 200 – 209 | Cross-contract auth (NotForgeton, NotAtlas) |
| 220 – 229 | Timelocks (UpgradeEtaTooSoon, UpgradeNotReady, UpgradeAlreadyPending, NoPendingUpgrade, EmptyCodeCell) |
| 240 – 249 | Reserve / value (InsufficientValue, ReserveInvariant, AmountExceedsAccumulated, BadWithdrawTarget) |
| 333       | WrongWorkchain — TEP convention (Phoebe is workchain-0-only) |
| 0xFFFF    | UnknownOpcode (dispatcher fallthrough) |

Plus common TVM codes (9 `CellUnderflow`, 13 `OutOfGas`, 37 `NotEnoughTon`).

Full table: [`ERRORS.md`](./ERRORS.md).

## Doc-surface decision tree — pick the right entry point

This SDK ships SIX agent-facing doc surfaces. Each answers a different
question; the table below is the routing.

| If the user is asking… | Reach for | Why |
|---|---|---|
| "How do I do `<X>`?" — paste-and-run code snippet for one task | **[`RECIPES.md`](./RECIPES.md)** (16 recipes) | Cookbook style; complete copy-paste blocks, one per task. Best for "I just need the lines of code." |
| "I'm an operator / consumer / owner / deployer / debugger" — multi-step playbook | **[`skills/`](./skills/)** (5 persona skills) | Each skill is a full playbook: prerequisites, steps, verification, troubleshooting. Best for "walk me through the whole flow." |
| "What's in this SDK?" — index-style overview | **`AGENTS.md`** (this file — Quick reference + Public surface tables) | Fast skim. Best for "what verbs/nouns does this SDK have?" |
| "Tell me everything about `<concept>`" — long-form prose | **[`llms.txt`](./llms.txt)** (15 KB skim) → **[`llms-full.txt`](./llms-full.txt)** (28 KB full) | LLM-context-optimized prose. Best for ingesting into a fresh AI agent's context window. |
| "What does opcode `0x<X>` / error `<N>` mean?" — reference lookup | **[`OPCODES.md`](./OPCODES.md)** + **[`ERRORS.md`](./ERRORS.md)** | Reference tables with cross-refs. Best for grep-ability. |
| "Generate JSON for my codegen / agent introspection" — machine-readable ABI | **`phoebe describe --json`** (CLI; `dist/cli.js`) | Full ABI as JSON: 27 opcodes + 38 errors + 14 sends + 10 getters + 5 struct shapes + 12 events + bounds + defaults. Best for "I'm an LLM and I need the schema." |
| "What's been guaranteed about behavior?" | **[`GUARANTEES.md`](./GUARANTEES.md)** | The contract's behavioral invariants in prose form (e.g. "lastSubmitter is never null after the first push"). |

**One-line rule of thumb:** RECIPES = "copy", skills = "follow", AGENTS = "skim", llms.txt = "ingest", OPCODES/ERRORS = "look up", `describe --json` = "parse", GUARANTEES = "rely on".

## Skill files — load one per task

Five persona-grouped skill files. Use `/skill-name` to invoke from a Claude Code-style assistant.

**Consumer / integrator:**
- [`skills/phoebe-integrate-consumer.md`](./skills/phoebe-integrate-consumer.md) — write a Tolk consumer that pulls prices from Phoebe

**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

## Project links

- [Phoebe contract source](../contracts/phoebe.tolk)
- [On-chain ABI](../contracts/messages.tolk)
- [Error table](../contracts/errors.tolk)
- [CLAUDE.md](../CLAUDE.md) — repo-wide AI development guide
- Sibling SDKs: [`@titon-network/atlas-sdk`](https://github.com/titon-network/atlas) (group-key backbone Phoebe binds to), [`@titon-network/forgeton-sdk`](https://github.com/titon-network/forgeton) (staking pool Phoebe mirrors operators from), [`@titon-network/fortuna-sdk`](https://github.com/titon-network/fortuna) (sibling VRF verifier consumer — same architectural pattern)
