Integration proofs
If you run a service whose users should be able to earn Goon Power for something they did there, you become an issuer: you sign a small statement, and the protocol verifies it.
Trust model#
An issuer is an ed25519 public key on an allowlist, with a list of activity types it may attest to, a maximum score per proof and a maximum per wallet per epoch. The protocol verifies who signed and that the statement is fresh, unique and within limits. It cannot verify that the activity took place. That is the issuer's word.
Proof fields#
| Field | Type | Rule |
|---|---|---|
| proof_id | string | 1–64 chars of A-Z a-z 0-9 _ . : -. Unique per issuer, forever. |
| issuer_id | string | Your allowlisted identifier. |
| wallet | base58 | The Solana address being credited. |
| activity_type | string | Must be on your allowlist entry. |
| epoch | integer | Period index: floor(unixTime / epochSeconds). The current period or the one before, if not yet settled. |
| score | integer | Whole Goon Power, 1 up to your per-proof limit. |
| expires_at | unix seconds | In the future, and no more than 7 days away. |
| nonce | string | Unique per issuer, forever. |
| signature | base58 | ed25519 signature over the canonical message. |
Canonical message#
The bytes you sign are these eleven lines, joined by a single newline, UTF-8, no trailing newline.
PoG-Integration-Proof-v1
chain:solana:mainnet
program:<program id>
issuer:<issuer_id>
proof:<proof_id>
wallet:<wallet>
activity:<activity_type>
epoch:<epoch>
score:<score>
expires:<expires_at>
nonce:<nonce>- The first line is a domain separator. A signature made for any other purpose cannot be reinterpreted as a proof.
- The chain and program lines bind the proof to one deployment. A devnet proof is invalid on mainnet.
- Use
solana:devnetorsolana:localneton those clusters.
Signing#
import { ed25519 } from "@noble/curves/ed25519";
import bs58 from "bs58";
const proof = {
proof_id: "evt-2031-000418",
issuer_id: "harbor-events",
wallet: "7xKX...",
activity_type: "event_attendance",
epoch: Math.floor(Date.now() / 1000 / 86400),
score: 5,
expires_at: Math.floor(Date.now() / 1000) + 3600,
nonce: crypto.randomUUID(),
};
const message = [
"PoG-Integration-Proof-v1",
"chain:solana:mainnet",
`program:${PROGRAM_ID}`,
`issuer:${proof.issuer_id}`,
`proof:${proof.proof_id}`,
`wallet:${proof.wallet}`,
`activity:${proof.activity_type}`,
`epoch:${proof.epoch}`,
`score:${proof.score}`,
`expires:${proof.expires_at}`,
`nonce:${proof.nonce}`,
].join("\n");
const signature = bs58.encode(ed25519.sign(new TextEncoder().encode(message), SECRET_SEED));
await fetch(`${API}/v1/proofs`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...proof, signature }),
});The repository ships the same logic as signIntegrationProof in @goon/shared, and as a command: pnpm --filter @goon/api issuer:sign.
Validation order#
- Shape: every field present and well-formed.
- Issuer exists and is active.
- Signature verifies against the issuer's key for this chain and program.
- Not expired; expiry within seven days.
- Activity type is on the issuer's list.
- Score is within the per-proof limit.
- Epoch is the current or previous period and has not settled.
- Neither
proof_idnornoncehas been seen from this issuer. - The wallet has room under the issuer's per-epoch cap. Credit is the smaller of the score and the remaining room.
| Code | Status | Meaning |
|---|---|---|
| unknown_issuer / issuer_suspended | 403 | Not on the allowlist, or suspended. |
| bad_signature | 403 | The signature does not verify for this chain, program and issuer. |
| expired / expiry_too_far | 400 | Outside the accepted validity window. |
| activity_not_allowed | 403 | Activity type not approved for this issuer. |
| score_too_high | 400 | Above the per-proof limit. |
| wrong_epoch | 400 | Not the current or previous period. |
| epoch_settled | 409 | That epoch has been settled; its allocations are final. |
| replay | 409 | The proof ID or nonce was already used. |
| issuer_daily_cap | 409 | The wallet has reached this issuer's cap for the epoch. |
Revocation#
Until the proof's epoch settles, the issuer can withdraw it. The credited Goon Power is voided and the wallet's score is recomputed. After settlement the allocation is part of an on-chain root and revocation is refused.
PoG-Integration-Revoke-v1
chain:solana:mainnet
program:<program id>
issuer:<issuer_id>
proof:<proof_id>POST /v1/proofs/revoke with { issuer_id, proof_id, signature }. Protocol admins can also revoke a proof or suspend an issuer.
Becoming an issuer#
- Generate a keypair (
pnpm --filter @goon/api issuer:keygen) and keep the seed in your own secret store. - Send the public key, the activity types you want to attest to, and expected volumes.
- The protocol admins add the allowlist entry with your limits. It appears at
GET /v1/issuers. - Test on devnet, then go live.