Protocol

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#

  1. 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.
  2. Final Goon Power is computed for every wallet with activity, in ascending epoch order because streaks depend on history.
  3. The pool is split. Each reward is added to that wallet's cumulative allocation.
  4. A Merkle tree is built over every wallet with a non-zero cumulative allocation.
  5. 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.
  6. The distribution file is stored in canonical form and hashed.
  7. The oracle publishes (epoch, root, totalAllocated, dataHash). The program applies its own checks.
  8. 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 / 0x01 prefixes 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.ts off-chain and programs/proof-of-goon/src/merkle.rs on-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.

FieldMeaning
version, cluster, programId, mintWhat this file is for. A file for one cluster or program cannot be confused with another.
period, epochThe off-chain period index and the on-chain epoch number.
scoringVersionVersion of the scoring config in force.
poolRawThe pool for this epoch, in raw units (6 decimals).
totalGpMilli, denominatorMilliNetwork Goon Power, and the denominator actually used after the floor.
distributedRawSum of this epoch's rewards.
cumulativeAllocatedRawSum 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.

verify.ts
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

  1. The program is activated and not paused.
  2. If a pending root has passed its delay, promote it first.
  3. The proof resolves leaf(wallet, cumulative) to the active root.
  4. cumulative is greater than what the wallet has already claimed.
  5. Total claimed after this payment does not exceed the root's declared total, nor the reserve.
  6. Transfer cumulative − claimed from 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#

SituationWhat happens
A pending root is found to be wrongThe 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 wrongThe guardian pauses. Paid claims cannot be reversed; a corrected root can only stop further payment.
The API's ledger cannot reproduce the on-chain rootThe 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 compromisedAt 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.