---
name: phoebe-operator-setup
description: Set up a Phoebe operator — stake at ForgeTON, register pkShare at Atlas, run the off-chain price aggregator + signer + pusher. Use when the user says "run a Phoebe operator", "register pkShare at Atlas", "set up snapshot pushing", or "operate Phoebe".
---

# Phoebe operator setup

You're an automaton operator. Your job: aggregate prices off-chain, threshold-BLS-sign `(timestamp, root)`, push the signed snapshot to Phoebe every cadence window. Phoebe verifies against Atlas's cached `groupPk` and accepts the freshest valid push.

## Pre-conditions

You need:

1. **A wallet with stake** — ≥ ForgeTON `minStake` + gas. Stake via `forgeton.sendRegisterAutomaton(yourSender, { value })`.
2. **A pkShare keypair** — solo-mode: any 32-byte secret; multi-op: a real DKG round (Atlas's solo-mode runbook covers this).
3. **A Phoebe deployment that has admitted you (transitively).** Phoebe must be a ForgeTON consumer (so `AutomatonSync` fan-outs reach Phoebe) AND an Atlas verifier (so `GroupKeySync` fan-outs reach Phoebe). The Phoebe owner handles both.

## Step 1 — generate keypair + register at Atlas

```typescript
import { randomBlsSecret, blsPublicKey } from '@titon-network/phoebe-sdk';
import { Atlas } from '@titon-network/atlas-sdk';

// Solo-mode (n=1, t=1) — your pkShare IS the group public key.
const operatorSk = randomBlsSecret();           // STORE SAFELY — HSM in production
const operatorPk = blsPublicKey(operatorSk);    // 48-byte G1 compressed

// In production: hand operatorPk (hex) to the Atlas owner; they publish it
// via `pnpm run publish:groupkey:testnet -- --pkshare <hex>`.
// Then YOU register it from the operator wallet:
const atlas = tonClient.open(Atlas.createFromAddress(atlasAddr));
await atlas.sendRegisterBlsShare(operatorSender, {
    value:   toNano('0.1'),
    pkShare: operatorPk,        // in solo-mode, must equal the published groupPk
});
```

In multi-op mode (n > 1), each operator generates their own pkShare; the group key is published as the aggregate; per-operator registration matches each operator's individual pkShare to the published group key via Atlas's DKG round.

For the solo-mode runbook see [`automaton/docs/fortuna-solo-mode.md`](https://github.com/titon-network/fortuna/blob/main/automaton/docs/fortuna-solo-mode.md) — the same pattern applies to Phoebe.

## Step 2 — stake at ForgeTON

```typescript
import { ForgeTON, FORGETON_DEFAULTS } from '@titon-network/forgeton-sdk';

const forgeton = tonClient.open(ForgeTON.createFromAddress(forgetonAddr));
await forgeton.sendRegisterAutomaton(operatorSender, {
    value: FORGETON_DEFAULTS.minStake + toNano('1'),    // generous gas slack
});
```

If Phoebe is admitted as a ForgeTON consumer, your registration auto-fan-outs into Phoebe → `OperatorMirrored { isActive: true, cause: CAUSE_FIRST_SYNC }` event.

Verify:

```typescript
import { Phoebe } from '@titon-network/phoebe-sdk';
const phoebe = tonClient.open(Phoebe.createFromAddress(phoebeAddr));

const me = await phoebe.getOperator(operatorAddr);
console.log(me);   // { isActive: true } when mirrored
```

If `me === null`, Phoebe wasn't admitted at ForgeTON when you staked — ask the Phoebe owner to fire `forgeton.sendSetConsumer(...)`. Once admitted, Atlas can re-mirror you via `phoebe.sendSyncAtlas` (owner-triggered) — though typically the next ForgeTON state change for your address will fan out anyway.

## Step 3 — build the snapshot pipeline

The push loop runs every ~30s (the cadence is your choice; aggressive cadences burn more gas, slow cadences make pulls staler).

```typescript
import {
    PhoebeMerkleTree,
    computeSnapshotHash,
    signMessage,
    estimatePushValue,
    Phoebe,
} from '@titon-network/phoebe-sdk';

const phoebe = tonClient.open(Phoebe.createFromAddress(phoebeAddr));

async function pushOnce() {
    // 1. Aggregate prices off-chain (CEX/DEX pulls; weighted median per asset).
    const aggregatedFeeds = await myAggregator.snapshot();
    //   ↳ Map<feedId, { mantissa, expo, confBps, pubTime }>

    // 2. Pack into PriceLeaf records.
    const leaves = new Map<number, PriceLeaf>();
    for (const [feedId, p] of aggregatedFeeds) {
        leaves.set(feedId, {
            feedId,
            mantissa: p.mantissa,
            expo:     p.expo,
            confBps:  p.confBps,
            pubTime:  p.pubTime,
        });
    }

    // 3. Build the 256-leaf tree. Empty slots get the canonical placeholder
    //    (feedId-only PriceLeaf with all other fields = 0).
    const tree = PhoebeMerkleTree.fromSparseLeaves(leaves);
    const root = tree.rootAsBigint();

    // 4. Sign (timestamp, root) with your pkShare secret.
    const timestamp = Math.floor(Date.now() / 1000);
    const sigInput = computeSnapshotHash(phoebe.address, timestamp, root);
    const aggSig = signMessage(operatorSk, sigInput);
    //   ↳ For multi-op: each operator returns their partial via signMessage();
    //     a designated aggregator combines via aggregateSignatures(partials).

    // 5. Push.
    try {
        await phoebe.sendPushSnapshot(operatorSender, {
            value: estimatePushValue(),    // 0.1 TON; excess refunds via mode 64
            timestamp,
            root,
            aggSig,
        });
    } catch (e) {
        // PhoebeError surfaces the contract's exit code + a hint.
        console.error('push failed:', e);
        // Common: 161 (DST drift), 181 (timestamp not monotonic), 182 (clock skew),
        // 140 (operator not mirrored), 180 (group key not yet cached).
    }

    // 6. Persist `tree.serialize()` (or just the leaves map) so consumers'
    //    indexers can build proofs against this snapshot's lastRoot.
}

setInterval(pushOnce, 30_000);
```

## Step 4 — expose proofs to consumers

Consumers need a Merkle proof per feedId they want to pull. They can:

- Run their own indexer that watches `EvtSnapshotPushed` events + caches the original leaves the operator signed.
- Query an operator-side or third-party proof API: `GET /proof?feedId=42` → `{ leaf, proof, root, timestamp }`.

The SDK's `PhoebeMerkleTree.proof(feedId)` returns a TVM merkle_proof exotic cell wrapping a pruned-branch tree — **~417 bytes per pull** (~20× smaller than the legacy full tree). The on-chain `verifyAndUnwrapProof` reads the wrapper's stored hash via the `XCTOS` opcode for the root-binding check. Legacy full-tree shape is still accepted (`tree.fullTreeProof(feedId)`, ~8.5 KB).

## Step 4b — expose the signed-data feed (mode B consumers)

`RequestPrice` mode B (Pyth-style "update + read") lets consumers attach a **freshly signed snapshot** inline with their pull, paying for the BLS-verify gas to get sub-heartbeat freshness. This requires consumers to fetch the most-recent signed `(timestamp, root, sig)` tuple from somewhere — the operator endpoint is the canonical source.

Add a second endpoint alongside `/proof`:

```
GET /signed-snapshot
→ {
    "timestamp": 1762800030,
    "root":      "0xf9b78c66...",        // hex, the same root you signed for the most recent push
    "sig":       "0x8a2bcd...",          // hex, 96-byte G2 aggregate sig from your most recent push
    "phoebeAddress": "EQ...",            // bound into the sig domain (cross-deployment defense)
    "leaves":    { "0": {...}, "42": {...}, ... }    // the leaves YOU signed, so consumers can build proofs
}
```

Crucial implementation notes:

1. **Cache your last successful push's `(timestamp, root, sig, leaves)` tuple in memory.** A consumer hitting `/signed-snapshot` immediately after your push should get the latest values — don't recompute the sig per-request.
2. **Serve the SAME `(timestamp, root, sig)` you submitted on-chain** — the contract checks `phoebeHash` binding + drift + strict-monotonic-vs-cached + BLS verify. If you serve a sig that doesn't match what you pushed, mode-B consumers will revert with `E_INVALID_BLS_SIGNATURE` (161) or `E_FRESH_NOT_FRESHER` (187) and eat the gas — they will stop trusting your endpoint.
3. **Sig stays valid until the next push.** Your `(timestamp, root, sig)` is consumable until either (a) cache advances to ≥ your `timestamp` (someone else pushed/fresh-updated), or (b) `now > timestamp + maxPushDrift` (~5min default). After that, the sig is dead — consumers querying it will revert with `E_FRESH_NOT_FRESHER` or `E_BAD_TIMESTAMP_DRIFT`.
4. **Optional: serve a buffer of recent (timestamp, root, sig) tuples**, so a consumer who race-loses to another consumer's mode-B advance can grab the next-newer one without waiting for your next push. Last 3-5 is plenty.
5. **Multi-op:** if you're a multi-op operator, your endpoint serves YOUR partial — the consumer-side aggregator combines partials from the t-of-n quorum into the aggregate sig before submitting. Same shape, just `partialSig` instead of `aggregateSig`. (Solo-mode is the default; multi-op ships in a follow-up.)

CORS-allow consumer origins so dapps can fetch this from a browser. Authentication is unnecessary — the data is publishable (the sig itself is the auth on-chain).

## Health monitoring

Critical metrics to track:

- **`phoebe.getSnapshot().lastSnapshotTime`** — should advance by the cadence. Stalls indicate the push loop is broken.
- **`phoebe.getOperator(myAddr).isActive`** — should be `true`. Becomes `false` if you unstake or get slashed at ForgeTON.
- **`phoebe.getGroupKey().groupEpoch`** — bumps on Atlas rotation. When you see a bump, update your `operatorSk` to the new-epoch share BEFORE pushing again (pre-rotation sigs revert with `E_INVALID_BLS_SIGNATURE`).
- **Push success rate** — failed pushes burn gas without advancing state. Watch for `E_INVALID_BLS_SIGNATURE` (DST or hash drift), `E_BAD_TIMESTAMP_DRIFT` (clock skew), `E_STALE_SNAPSHOT` (race-loss; another operator pushed first).

## Watch out for

- **Wrong DST.** The #1 cause of `E_INVALID_BLS_SIGNATURE`. Always use `signMessage` from this SDK; never `bls.longSignatures.sign(point, sk)` directly (that uses the noble-curves default `_NUL_` DST which TVM rejects).
- **Clock drift.** `E_BAD_TIMESTAMP_DRIFT` (182). Run NTP. Default `maxPushDrift` is 300s — generous, but a wildly-wrong clock still trips it.
- **Stale-pkShare after rotation.** When Atlas rotates the group key (epoch bump), your old share is invalid. Update before pushing.
- **Race losses are normal.** With multiple operators, only the first-to-push for a given timestamp lands. Subsequent pushes revert with `E_STALE_SNAPSHOT`. Use jitter (random backoff) to spread the losses fairly.
- **Reward economics.** Every accepted `RequestPrice` splits `pullFee` — `pullFee * operatorBps / 10000` credits the most-recent pusher's (`lastSubmitter`) unclaimed-rewards entry; the remainder lands in `rewardPool` (owner-collected). Default `operatorBps = 7500` (75% operator / 25% owner). Mode B (consumer-attached fresh-update) does NOT change `lastSubmitter` — only your real `PushSnapshot` makes you the current beneficiary.
- **`maxInactivity` filter (if owner-enabled).** If the owner has set `maxInactivity > 0`, your operator share rolls into `rewardPool` once your `lastPushAt` is older than `now - maxInactivity`. Keep pushing on cadence (or back off and yield to a more active operator) — silence forfeits earnings.
- **Don't push if `getOperator(addr).isActive === false`.** Saves gas. Re-stake at ForgeTON to re-activate.

## Claiming accrued rewards

Push a snapshot, get consumers to pull against it, and you accrue `operatorBps/10000` of every `pullFee` to your operator-keyed `operatorRewards` entry. Subsequent pushes by OTHER operators displace you as `lastSubmitter` — but your accrued balance is preserved indefinitely. Claim when economical (claim gas ≈ 0.03 TON, so let the balance grow before sweeping).

```typescript
const unclaimed = await phoebe.getUnclaimedReward(operatorAddress);
if (unclaimed > toNano('0.05')) {              // arbitrary economic threshold
    await phoebe.sendClaimReward(operatorWallet.sender(), {
        value:  toNano('0.05'),                 // covers gas; excess refunded
        to:     payoutAddress,                  // can be a separate treasury / cold wallet
        amount: 0n,                             // 0n = claim all; or specify exact nanoTON
    });
}
```

Reverts:
- `OperatorNotFound (140)` — sender is not in `storage.operators` (ForgeTON deactivated AND never mirrored, OR you're sending from a wallet that isn't your registered operator address).
- `NoUnclaimedReward (188)` — your unclaimed balance is zero (you never accrued, OR you already drained).
- `AmountExceedsAccumulated (242)` — non-zero `amount` exceeds your current balance.

"Earned is earned": even if ForgeTON deactivates you (`isActive: false`), you can still claim previously-accrued balances. The map gate is presence, not activity.

## Cross-references

- [`AGENTS.md`](../AGENTS.md) — full SDK surface
- [`phoebe-deploy.md`](./phoebe-deploy.md) — owner-side admit flow
- [`phoebe-debug.md`](./phoebe-debug.md) — when pushes fail
- [Atlas SDK skills](https://github.com/titon-network/atlas/tree/main/sdks/typescript/skills) — pkShare registration + DKG runbook
