# GUARANTEES.md — what this SDK guarantees, and what it doesn't

A precise contract between `@titon-network/phoebe-sdk` and the developers using it. Reading this once spares you a class of debugging.

---

## Cryptographic guarantees (inherited from the contract)

When `FulfillPrice` lands at your consumer, you can trust:

1. **The leaf was signed by the operator group.** Phoebe verified `BLS_VERIFY(cachedGroupPk, computeSnapshotHash(this.address, timestamp, root), aggSig)` before accepting the snapshot the leaf came from. This holds whether the snapshot landed via `PushSnapshot` (operator heartbeat) OR via `RequestPrice.freshUpdate` (consumer-attached inline) — both paths run the same BLS verify against the same cached `groupPk`. If you trust Atlas (the source of `groupPk`), you trust the leaf.
2. **The signature binds to THIS Phoebe deployment.** The signed payload includes the destination contract's address-hash as its first 32 bytes. Two Phoebe deployments sharing the same Atlas group key cannot accept each other's snapshots — a sig produced for Phoebe A reverts at Phoebe B with `WrongDeploymentBinding` (144) before BLS verify even runs (on either the push or the fresh-update path). Cross-deployment replay is closed cryptographically, not just operationally.
3. **The leaf is at the slot you asked for.** Phoebe asserted `walkMerkleProof(proof, msg.feedId).hash() == leaf.toCell().hash()` AND `leaf.feedId == msg.feedId`. Off-chain attackers cannot deliver a leaf at slot N while claiming `leaf.feedId = M`.
4. **The snapshot is recent enough.** If you set `maxStaleness > 0`, Phoebe asserted `(now - activeSnapshotTime) <= maxStaleness` before walking the proof — where `activeSnapshotTime` is the cached `lastSnapshotTime` (mode A) OR your `freshUpdate.timestamp` (mode B). Attaching a `freshUpdate` lets you satisfy a tight `maxStaleness` the cache would fail.
5. **The caller is Phoebe, not a spoof.** Your consumer must `assert (sender == storage.phoebe)` in `handleFulfillPrice`. Without this assertion, anyone can deliver a fake `FulfillPrice` body. The SDK's [`templates/consumer.tolk`](./templates/consumer.tolk) bakes this in.
6. **Fresh-update advances the cache atomically.** When mode B succeeds, the on-chain `lastSnapshotTime` / `lastRoot` advance to your fresh values BEFORE the Merkle walk against your proof. Subsequent pulls in the same block read the advanced cache for free (positive externality — your fee buys freshness for the whole block).

---

## Operator economics + liveness filter

7. **`pullFee` splits deterministically per `operatorBps`.** Every accepted `RequestPrice` credits `pullFee * operatorBps / 10000` to the **most-recent `PushSnapshot` submitter** (`lastSubmitter`); the deliberate `(10000 - operatorBps)/10000` share lands in `rewardPool` (owner-withdrawable via `WithdrawFees`). Default at deploy: 7500 bps (75% to lastSubmitter / 25% to owner).
8. **Only `PushSnapshot` (mode A) updates `lastSubmitter`.** Consumer-attached `freshUpdate` (mode B) advances the cache but does NOT change `lastSubmitter`. Consumers cannot route the operator share to themselves — the prior pusher keeps accruing until a real operator push displaces them.
9. **Pre-first-push grace: 100% to `rewardPool`.** When `lastSubmitter` is null (no operator has pushed yet) OR the silent-operator filter drops the slot (see #10), pulls accrue the full `pullFee` to `rewardPool`.
10. **`maxInactivity` pull-side filter excludes silent operators from the 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. Consumers literally cannot pay an operator who has stopped working. When `maxInactivity == 0`, the filter is disabled — `lastSubmitter` always receives the operator share regardless of silence.
11. **`ClaimReward` is operator-gated, not active-gated.** Sender must exist in `storage.operators` (mirrored from ForgeTON), but `isActive` is NOT checked — rewards accrued while active survive ForgeTON deactivation. "Earned is earned."
12. **`ClaimReward.to` is free.** Operators can route payout to a treasury / cold wallet without rotating their submitter key. The `EvtRewardClaimed` event captures both `claimant` (the operator that signed the claim) and `to` (the routing target).
13. **`amount: 0n` means "claim entire unclaimed balance".** Non-zero `amount` claims exactly that many nanoTON and reverts `AmountExceedsAccumulated` (242) if it exceeds the balance. On full claim, the map entry is **deleted** (not zeroed), so an immediate re-claim hits `NoUnclaimedReward` (188) rather than returning a fresh zero.
14. **Operator unclaimed balances are NOT in `rewardPool`.** `WithdrawFees` only drains the owner share. The contract retains both pools' backing in real balance via the same `RAWRESERVE` discipline that lands `pullFee` on every accepted pull.
15. **Phoebe is carrot-only.** No `Slash` outbound from Phoebe — operators self-opt-out by simply not pushing. Bad-data slashing is governance-subjective and out of scope.

---

## ABI guarantees

1. **Wire shapes are frozen.** `RequestPrice` (0x71) + `FulfillPrice` (0x72) + `PriceLeaf` field order will never change. Future opcodes will be added alongside; the current surface stays callable. `RequestPrice.freshUpdateRef` is optional — omitting it (`null`) preserves the original cached-read semantics; setting it is purely additive.
2. **Schema versioning is enforced.** Both `PHOEBE_STORAGE_VERSION` + `PHOEBE_CONFIG_BLOB_VERSION` are checked at every handler entry. SDK ↔ contract drift surfaces as `BadSchemaVersion` (110), not silent corruption.
3. **`storage.forgeton` and `storage.atlas` are immutable post-deploy.** Repointing requires a timelocked code upgrade. Live consumers can cache these addresses without polling.
4. **Code-hash + schema versions are exposed via the SDK.** `assertDeployment(network)` returns the canonical `expectedCodeHash` + `expectedSchema`; compare against `(await client.getContractState(addr)).codeHash` + `phoebe.getSchemaVersions()` to catch drift.

---

## Off-chain guarantees

1. **`computeSnapshotHash(phoebeAddress, timestamp, root)`** is byte-identical to the on-chain `signedDataRef` slice — 68 bytes: `address.hash || be32(timestamp) || be256(root)`. Operators who sign this buffer with `signMessage(sk, ...)` produce sigs that verify on-chain unmodified. The first argument is the destination Phoebe contract's `Address` — the SDK encodes its 32-byte hash into the signed bytes for cross-deployment replay defense (guarantee #2 above).
2. **`signMessage` always uses `BLS_DST_G2_POP`.** The default `_NUL_` DST is the #1 cause of `InvalidBlsSignature` (161); the SDK's `signMessage` bakes the right DST in.
3. **`PhoebeMerkleTree`** produces proofs whose `proof.hash() == tree.rootAsBigint()` — byte-identical to what Phoebe's `walkMerkleProof` expects. `tree.proof(feedId)` is canonical for any feedId in `[0, MAX_FEED_COUNT)`.
4. **Event decoders** are byte-stable against the on-chain emit format. New event opcodes are added to the SDK in a minor version bump; existing events stay decodable across SDK versions.

---

## Sandbox-fixture guarantees

1. **`deployPhoebeFixture(blockchain)` returns a Phoebe ready for push + pull.** With `autoBootstrap !== false` (default), the group key is cached and the operator is mirrored.
2. **The fixture's group key + operator are deterministic per `blockchain.now`.** Re-running a test with the same time pin produces the same address derivation, same group key, same operator address.
3. **`fx.pushSnapshot(...)` always signs with the right DST + the right hash domain.** Snapshots produced this way always succeed `BLS_VERIFY` on-chain (assuming you don't manually corrupt the resulting `aggSig` before submission).
4. **`fx.requestPrice(...)` always builds a valid Merkle proof for the supplied tree.** As long as `feedId` is in `[0, MAX_FEED_COUNT)` and you pass the same `leaf` you put in the tree, the contract will accept the proof.

---

## What the SDK does NOT guarantee

These are explicit non-goals — assuming any of them is a bug.

1. **The SDK does not validate live deployment addresses.** `PHOEBE_TESTNET` / `PHOEBE_MAINNET` are static snapshots captured at sync time. Always read `phoebe.getSchemaVersions()` + `getOwnership()` against the live contract before sending real money — drift surfaces as `BadSchemaVersion` revert at the next state-mutating call, not at SDK import.
2. **The SDK does not validate `PriceLeaf.mantissa` / `expo` / `confBps`.** The contract accepts whatever the operator signed; consumer-side sanity checks (mantissa > 0 for prices that can't be negative, conf within bounds) belong in your business logic.
3. **The SDK does not check the operator's clock.** Operators with skewed clocks produce snapshots that revert with `BadTimestampDrift` (182) until they sync NTP. The SDK's `computeSnapshotHash` uses whatever timestamp you pass in.
4. **The SDK does not retry failed sends.** A bounced `RequestPrice` (e.g., contract paused, gas underflow, stale proof) returns the result to the caller; retry logic + back-off belongs in the consumer.
5. **The SDK does not gas-estimate against live Atlas state.** `estimatePullValue` uses the SDK-default tunables; if the live contract has a different `pullFee`, read it via `phoebe.getConfig().pullFee` and feed that in.
6. **The SDK does not parse `FulfillPrice` callbacks for you.** Use `parseFulfillPrice(body)` inside your consumer's receiver — but it's the consumer's responsibility to call it.
7. **The SDK does not implement reorg handling.** TON has fast finality; if you're worried about same-block reorgs, wait N confirmations before acting on a `FulfillPrice` callback.
8. **The fixture's mock-ForgeTON is NOT a real ForgeTON.** It's a treasury that Phoebe recognises as the trusted `AutomatonSync` sender. For tests that exercise real ForgeTON staking / slashing flows, use `@titon-network/forgeton-sdk/testing` directly and wire the addresses manually.

---

## Threat model assumptions

The on-chain Phoebe contract assumes:

- **The group key is honest.** Atlas's group is t-of-n threshold-honest. If t operators collude, they can sign arbitrary `(timestamp, root)` pairs, including ones that pin a fake price. Mitigation belongs at the Atlas / ForgeTON layer (operator diversity + slashing).
- **The owner is honest.** The owner can pause the contract, change tunables, withdraw fees, and (after a 24h timelock) replace the contract code. Production deployments use a multisig owner.
- **The deployment topology is correct.** Phoebe binds to one ForgeTON + one Atlas at deploy. If the addresses are wrong, the consumer has bigger problems than this SDK can solve.
- **Cross-Phoebe signature replay is closed cryptographically.** The `SignedSnapshot` struct in messages.tolk prefixes the signed payload with the destination Phoebe's 32-byte address hash, plus the contract asserts its own workchain == 0 (`E_WRONG_WORKCHAIN = 333`). Two Phoebe deployments sharing the same Atlas group key cannot accept each other's signed payloads — neither operator-pushed (mode A `PushSnapshot 0x70`) NOR consumer-attached (mode B `RequestPrice.freshUpdate`). A sig produced for Phoebe A reverts at Phoebe B with `WrongDeploymentBinding` (144) before BLS verify even runs, on either path.
- **Mode B (consumer-attached fresh updates) is permissionless.** Anyone can submit a `RequestPrice` with `freshUpdate` set, including non-operators. The crypto auth is the BLS aggregate verify under Atlas's cached `groupPk` — operator-set membership is NOT checked on the fresh-update path (it is on PushSnapshot, as defense-in-depth + spam prevention; consumer-paid gas covers spam prevention for mode B). This is intentional and matches Pyth's "anyone can post a price update" design — the trust root remains Atlas's threshold-honest group.

---

## Versioning + upgrade discipline

- **SDK semver:** breaking changes to the public surface bump the major; new features bump the minor; bug fixes bump the patch.
- **Contract schema:** `PHOEBE_STORAGE_VERSION` + `PHOEBE_CONFIG_BLOB_VERSION` track the on-chain layout. Schema bumps are minor SDK bumps; consumers reading getters on a drifted contract get `BadSchemaVersion` rather than malformed data.
- **Upgrade flow:** the contract owner can upgrade bytecode via `Propose` → wait 24h → `Execute`. Off-chain observers should monitor `EvtCodeUpgradeProposed` events to react during the timelock window.
- **`MAX_FEED_COUNT = 256`.** Bumping ships via the standard timelocked code upgrade (`Propose` → 24h → `Execute`); the new code lifts both `MAX_FEED_COUNT` and `TREE_DEPTH` in lockstep, and consumer wrappers must update.
