Settlement and claims
How an epoch's scores become an on-chain commitment, how a wallet withdraws against it, and how to check all of it without trusting the operator.
Pipeline#
- The epoch ends. After a 10-minute grace window the settlement engine takes an exclusive lock, so no proof can enter an epoch while it is being settled.
- Final Goon Power is computed for every wallet with activity, in ascending epoch order because streaks depend on history.
- The pool is split. Each reward is added to that wallet's cumulative allocation.
- A Merkle tree is built over every wallet with a non-zero cumulative allocation.
- The engine asserts that the sum of the leaves equals the ledger total and that the total does not exceed the schedule. If either fails, nothing is written.
- The distribution file is stored in canonical form and hashed.
- The oracle publishes
(epoch, root, totalAllocated, dataHash). The program applies its own checks. - After the settlement delay the root is claimable.
Tree construction#
leaf = sha256( 0x00 || "PoG-claim-v1" || wallet[32] || cumulative_amount[u64 little-endian] ) node = sha256( 0x01 || min(a, b) || max(a, b) )
- Leaves are sorted by wallet bytes. An odd node at any level is promoted unchanged.
- The
0x00/0x01prefixes make it impossible to present an inner node as a leaf. - The domain tag binds a leaf to this protocol, so it cannot be replayed against another program.
- Siblings are hashed in sorted order, so a proof is just a list of hashes with no left/right flags.
- The same code path exists twice:
packages/shared/src/merkle.tsoff-chain andprograms/proof-of-goon/src/merkle.rson-chain. The program test suite proves roots built by the first verify in the second, including a 4,097-leaf tree.
The distribution file#
GET /v1/epochs/:period/distribution returns the exact bytes whose SHA-256 is on-chain. It is canonical JSON: keys sorted, no whitespace.
| Field | Meaning |
|---|---|
| version, cluster, programId, mint | What this file is for. A file for one cluster or program cannot be confused with another. |
| period, epoch | The off-chain period index and the on-chain epoch number. |
| scoringVersion | Version of the scoring config in force. |
| poolRaw | The pool for this epoch, in raw units (6 decimals). |
| totalGpMilli, denominatorMilli | Network Goon Power, and the denominator actually used after the floor. |
| distributedRaw | Sum of this epoch's rewards. |
| cumulativeAllocatedRaw | Sum of every leaf. Equals the on-chain total. |
| rewards[] | { wallet, gpMilli, rewardRaw } for each wallet paid this epoch. |
| cumulative[] | { wallet, cumulativeRaw } for every wallet: the Merkle leaves, in tree order. |
Verify a root yourself#
Nothing here needs the operator's cooperation beyond serving the file. Compare the two printed values with the program's state account.
import { sha256 } from "@noble/hashes/sha2";
import bs58 from "bs58";
const hex = (b: Uint8Array) => Buffer.from(b).toString("hex");
const cmp = (a: Uint8Array, b: Uint8Array) => Buffer.compare(Buffer.from(a), Buffer.from(b));
const cat = (...p: Uint8Array[]) => Uint8Array.from(Buffer.concat(p.map((x) => Buffer.from(x))));
const body = await (await fetch(`${API}/v1/epochs/${period}/distribution`)).text();
console.log("data hash:", hex(sha256(new TextEncoder().encode(body))));
const file = JSON.parse(body);
let layer: Uint8Array[] = file.cumulative.map((l: { wallet: string; cumulativeRaw: string }) => {
const amount = Buffer.alloc(8);
amount.writeBigUInt64LE(BigInt(l.cumulativeRaw));
return sha256(cat(Uint8Array.of(0), new TextEncoder().encode("PoG-claim-v1"), bs58.decode(l.wallet), amount));
});
while (layer.length > 1) {
const next: Uint8Array[] = [];
for (let i = 0; i < layer.length; i += 2) {
const [a, b] = [layer[i], layer[i + 1]];
next.push(b ? sha256(cmp(a, b) <= 0 ? cat(Uint8Array.of(1), a, b) : cat(Uint8Array.of(1), b, a)) : a);
}
layer = next;
}
console.log("root:", hex(layer[0]));
const sum = file.cumulative.reduce((s: bigint, l: { cumulativeRaw: string }) => s + BigInt(l.cumulativeRaw), 0n);
console.log("total matches:", sum === BigInt(file.cumulativeAllocatedRaw));To go further, recompute each reward from rewards[].gpMilli, poolRaw and denominatorMilli; the result must match rewardRaw exactly.
Claiming#
GET /v1/claims/:wallet returns the wallet's cumulative amount in the settled root, what it has already claimed, the difference, and the Merkle proof. The endpoint is public: a proof is not a secret, and tokens can only ever go to the wallet named in the leaf.
What the program checks
- The program is activated and not paused.
- If a pending root has passed its delay, promote it first.
- The proof resolves
leaf(wallet, cumulative)to the active root. cumulativeis greater than what the wallet has already claimed.- Total claimed after this payment does not exceed the root's declared total, nor the reserve.
- Transfer
cumulative − claimedfrom the vault to the wallet's associated token account, creating it if needed.
- The transaction payer does not have to be the wallet. A relayer can pay the fee; the tokens still go to the wallet in the leaf.
- Claims never expire. A later root contains the same or a larger cumulative amount.
- A claim against a superseded root fails with an invalid proof. Fetch the current proof and retry.
When something is wrong#
| Situation | What happens |
|---|---|
| A pending root is found to be wrong | The guardian cancels it. The API detects the cancellation, holds automatic publishing, and waits for an admin to fix the cause and re-run settlement. |
| A root has already settled and is wrong | The guardian pauses. Paid claims cannot be reversed; a corrected root can only stop further payment. |
| The API's ledger cannot reproduce the on-chain root | The claims endpoint refuses to serve proofs and returns an error, rather than hand out proofs for a root it cannot explain. |
| The oracle key is compromised | At most the emission the schedule has already released can be misdirected, one root at a time, each visible for the full delay. The admin rotates the key. |