Verifying tokens
Your server may be handed these signed answers; each is a claim to check before you act on it:
| What it says | Where it comes from | Key set | |
|---|---|---|---|
| Session token | This wallet (sub) is signed in and using your app (aud) on a coin page, now (exp, 5 minutes), and whether it has proven an account (idv) | Your app sends the session-token action (needs net:connect) and passes the token to your server | https://api.chaindaddy.io/api/v2/apps/jwks |
| Receipt | This wallet bought this item (sku, quantity) in your store (storeId) | iap-purchase answers confirmed with it; also GET /api/v2/iap/me/receipts | https://api.chaindaddy.io/api/v2/iap/jwks |
| Signed answer | This ticker is registered on this chain and this wallet holds the registration, or it is not registered, or its page was removed (status), as of now (exp, 15 minutes). Or what the coin's creator record says (kind: "creator_record") | GET /api/v2/public/signed/coins/{chain}/{symbol} and …/creator-record, from Chain Daddy or from anyone who passes it on | The receipts' key set, also named on chaindaddy.eth. See Signed answers |
All three are compact JWS signed with Ed25519 (alg: EdDSA), checked offline against the platform's published keys. Never trust one because a client sent it.
The rules
- Signature. EdDSA, against the right key set: session tokens and receipts are signed with different keys. Each key set is a JWKS of
OKP/Ed25519keys; pick the key by the header'skid. The set lists the current key first and keeps the previous one after a rotation. A signed answer is signed with the receipts' keys; itstypclaim tells the two apart. typis a claim. The header'stypis a plainJWT. What the token is, is itstypclaim:app-session+v1,iap-receipt+v1orcd-answer+v1. Check the claim.issishttps://chaindaddy.io.- Session tokens.
audis your app id, andexpis in the future, allowing 30 seconds of clock skew. To let only verified people in, also requireidv === true, and treat a token withoutidvas not verified. Every claim, and whatidvmeans, is in Session tokens. - Receipts.
storeIdis your store. Credit eachorderIdonce. An offline check can't see a refund made since: for anything that matters after the fact, also check online withPOST /api/v2/iap/receipts/verify(Verifying receipts).
In TypeScript, with jose
import { createRemoteJWKSet, jwtVerify, type JWTPayload, type JWTVerifyGetKey } from 'jose';
const API = 'https://api.chaindaddy.io'; // the platform API: where the public keys are
const ISSUER = 'https://chaindaddy.io'; // the `iss` of everything Chain Daddy signs
// Two key sets: session tokens and receipts are signed with different keys. Each is cached, and refetched on an
// unknown key id, so a key rotation needs nothing from you.
const sessionKeys = createRemoteJWKSet(new URL('/api/v2/apps/jwks', API));
const receiptKeys = createRemoteJWKSet(new URL('/api/v2/iap/jwks', API));
/** A session token (the `session-token` action) for your app: `sub` is the signed-in visitor's wallet. */
export async function verifySession(token: string, appId: string, keys: JWTVerifyGetKey = sessionKeys, now?: Date): Promise<JWTPayload> {
const { payload } = await jwtVerify(token, keys, { algorithms: ['EdDSA'], issuer: ISSUER, audience: appId, clockTolerance: 30, currentDate: now });
// what a token IS lives in its claims: the header's `typ` is a plain "JWT"
if (payload.typ !== 'app-session+v1') throw new Error('not an app session token');
if (!payload.exp) throw new Error('the session token has no expiry');
return payload;
}
/** A receipt from a confirmed purchase in your store. Credit each `orderId` once. */
export async function verifyReceipt(receipt: string, storeId: string, keys: JWTVerifyGetKey = receiptKeys, now?: Date): Promise<JWTPayload> {
const { payload } = await jwtVerify(receipt, keys, { algorithms: ['EdDSA'], issuer: ISSUER, currentDate: now });
if (payload.typ !== 'iap-receipt+v1') throw new Error('not an in-app purchase receipt');
if (payload.storeId !== storeId) throw new Error('the receipt is for another store');
return payload;
}sub of a verified session is the visitor's wallet (lowercase 0x… on EVM, base58 on Solana): sign them in to your server with it, with no wallet prompt. To let only verified people in:
const session = await verifySession(token, APP_ID);
if (session.idv !== true) throw new Error('prove an account on Chain Daddy first');Signed answers
A signed answer is what Chain Daddy says about a coin's registration, from the signed read. Because it is signed, you can take it from anyone: a cache, a gateway, another site's server. verifyAnswer in @chaindaddy/js/verify checks one:
import { verifyAnswer } from '@chaindaddy/js/verify';
const { jws } = await fetch('https://api.chaindaddy.io/api/v2/public/signed/coins/base/FLUXGP').then((r) => r.json());
const answer = await verifyAnswer(jws, { expect: { chain: 'base', symbol: 'FLUXGP' } });
if (answer.status === 'registered') {
console.log(answer.holder); // the wallet that holds the registration
}It resolves the claims only when the JWS is signed with EdDSA by one of the keys, its typ is cd-answer+v1, its iss is https://chaindaddy.io and exp has not passed. Anything else rejects with a VerifyError whose code says why: bad_answer, unknown_key, bad_signature, expired, expectation_mismatch, bad_keys or keys_unavailable (and from verifyRecord, record_mismatch or answer_unavailable).
- Pass
expect. Whoever hands you answers can hand you a genuine one about another coin.expectrefuses an answer that is not about the chain and ticker you asked for. - Read
statusfirst. A coin that is not registered verifies too: its answer is signed and saysnot_registered. Onlyregisterednames aholder. - Do not keep an answer past
exp. It says what was true when it was signed. Fetch a new one.
A position
A holder can copy their own position on a coin as a signed answer of kind position (the share sheet's Copy proof). It says what their wallets held and made on that coin when it was signed: status is held or closed, and position carries the figures (profitUsd, spentUsd, soldUsd, holdingAmount, the average entry, firstBuyAt, computedAt) and the wallets they sum. It is valid for 24 hours. verifyAnswer checks it like any other answer:
const answer = await verifyAnswer(pastedJws, { expect: { chain: 'base', symbol: 'DOGBOX' } });
if (answer.kind === 'position') {
console.log(answer.position.profitUsd, answer.position.wallets); // signed by Chain Daddy
}A creator record
The signed creator record is a signed answer of kind creator_record: a summary of the coin's creator record in record, and beside the JWS the whole record, whose SHA-256 is record.recordHash. verifyRecord checks both:
import { verifyRecord } from '@chaindaddy/js/verify';
const { record, creatorRecord } = await verifyRecord('https://api.chaindaddy.io/api/v2/public/signed/coins/base/MOON/creator-record');
if (record.launchesTotal !== undefined) {
console.log(`${record.launchesTotal} launches · ${record.launchesAlive} still trading`); // signed
}
console.log(creatorRecord?.definitions); // the whole record, checked against record.recordHash- From that URL,
verifyRecordfetches the answer and the record beside it, and refuses an answer about any other chain or coin than the one the URL names. From a JWS (one somebody pasted), passexpect, and pass the record ascreatorRecordwhen you have a copy: it must hash torecordHash, or the check rejects withrecord_mismatch. - Read
record.statefirst.not_on_record(no verified page for that ticker on that chain, or one Chain Daddy took down) andpending(the record is still being read) verify too, and carry no counts. - The counts can grow while
record.offPlatformPendingistrue: they are what Chain Daddy had read from the chains when the answer was signed. - A coin nobody registered is asked for by its token address in place of the ticker. Its answer names the coin by
tokenAddressand has nosymbol;expect: { chain, tokenAddress }checks it.
By hand, the hash is SHA-256 over the record in canonical JSON (RFC 8785): in JavaScript, JSON.stringify with every object's keys sorted, hashed as UTF-8, and compared in lowercase hex.
A launch record
A coin launched with Chain Daddy has a launch record: its terms at launch and its creator's earlier crowns. The public read serves the record's bytes exactly as they were hashed, and its SHA-256 is the crown's verificationProofHash on chain, so you check it against the chain, not against us:
import { verifyLaunchRecord } from '@chaindaddy/js/verify';
const url = 'https://api.chaindaddy.io/api/v2/public/coins/base/MOON/launch-record';
const { recordHash, record, onChain } = await verifyLaunchRecord(url, {
crown: { registry: '0x…the OpenCrown registry on Base', crownId: 12 }, // X-Record-Crown names the crown
rpcUrl: 'https://mainnet.base.org', // yours
});
console.log(onChain ? 'the crown commits this record' : 'kept and signed by Chain Daddy', record.terms);- Hash the bytes as received. Never parse and re-encode the record before hashing: the record is its bytes.
X-Record-Hashnames the hash we computed, for a quick comparison. - On chain means
getCrown(crownId).verificationProofHashequals the hash (on Solana, the crown account'sverification_proof_hash). A crown minted before records went on chain carries another hash; its record is still served, and signed: the signed launch record is a signed answer of kindlaunch_recordnamingrecordHash,crownId,onChainandurl. - Walk the creator's launches: each
previous[]entry names an earlier crown and its ownrecordHash; check each against that crown the same way.
Where the keys are
With no keys, verifyAnswer fetches the key set at https://api.chaindaddy.io/api/v2/iap/jwks. The same keys are named on chaindaddy.eth, in the text record io.chaindaddy.signing-keys: ed25519:<x> entries separated by a space, the current key first, where <x> is the key's x in the key set. Read the record yourself and neither the answer nor the keys have to come from Chain Daddy's servers:
import { createPublicClient, http } from 'viem';
import { mainnet } from 'viem/chains';
import { normalize } from 'viem/ens';
import { SIGNING_KEYS_RECORD, signingKeysFromRecord, verifyAnswer } from '@chaindaddy/js/verify';
const ethereum = createPublicClient({ chain: mainnet, transport: http(process.env.ETH_RPC_URL) });
const record = await ethereum.getEnsText({ name: normalize('chaindaddy.eth'), key: SIGNING_KEYS_RECORD });
const keys = signingKeysFromRecord(record ?? ''); // [{ kid, x }, …], the current key first
const answer = await verifyAnswer(jws, { keys, expect: { chain: 'base', symbol: 'FLUXGP' } });signingKeysFromRecord skips an entry of a kind it does not know and throws on an ed25519: entry that is not a key. The package does not read Ethereum itself: use the client you already have.
By hand
In another language: split the JWS at the dots and decode each part from base64url. The header's alg must be EdDSA, and its kid picks the key. Check the Ed25519 signature over the first two parts as sent, joined by a dot. Then check the claims: typ is cd-answer+v1, iss is https://chaindaddy.io, exp is in the future, and chain and symbol are what you asked about (compare the ticker without regard to case).
In another language
That is the whole check; any JOSE library with EdDSA does it. For Go, see server/cdapi in flux-gp-kit.
Test vectors
tokens.json holds two published key sets (jwks.session, jwks.receipts), a fixed time now (Unix seconds), an appId, a storeId, and 15 tokens marked valid or not: expiry and clock skew, another app, another issuer, typ only in the header, no expiry, a forgery under the published key id, a receipt offered as a session token and the reverse, and another store. Their key ids are readable names rather than hashes.
Run your check over every case at now: it passes when it accepts exactly the valid ones. With that code:
import { readFileSync } from 'node:fs';
import { createLocalJWKSet } from 'jose';
import { verifyReceipt, verifySession } from './verify-tokens';
const v = JSON.parse(readFileSync('tokens.json', 'utf8'));
const now = new Date(v.now * 1000);
const sessionKeys = createLocalJWKSet(v.jwks.session);
const receiptKeys = createLocalJWKSet(v.jwks.receipts);
for (const c of v.cases) {
const accepted = await (c.kind === 'session'
? verifySession(c.token, v.appId, sessionKeys, now)
: verifyReceipt(c.token, v.storeId, receiptKeys, now)
).then(() => true, () => false);
if (accepted !== c.valid) throw new Error(`wrong answer for: ${c.name}`);
}