Pontifex API Reference
This page is for developers who work with the Pontifex bridge directly: wallets, scripts, indexers, and reviewers. For the app flow, see Pontifex Bridge.
Model
Pontifex keeps two running totals for every recipient address. Both only grow.
| Total | Chain | Contract | Grows when |
|---|---|---|---|
burned[recipient] | Igra | KskdExit | exit burns KSKD for recipient |
bridgedIn[recipient] | Robinhood Chain | KskdBridged | KskdEntry.claim mints to recipient |
The bridge enclave signs a claim over (recipient, cumulativeBurned, deadline). KskdEntry mints cumulativeBurned − bridgedIn[recipient], or less if the window budget is short. It then raises bridgedIn[recipient] by the amount minted.
- The amount pending for any recipient is
burned − bridgedIn. - A signature can be submitted again, but it mints nothing once
bridgedInhas caught up. - A newer signature covers every earlier burn, so a missed claim never has to be submitted.
Kskd Exit. exit (amount, recipient)burned[recipient] += amount- emits
Exited
POST /bridge/sign- reads
burned[recipient]atmin(finalized, head − 800) - signs an EIP-712
Claim
Kskd Entry. claim (recipient, cumulative Burned, deadline, signatures)KskdBridgedmints the difference- emits
Claimed
Addresses
| Contract | Network | Address | Deployed at block |
|---|---|---|---|
| KSKD | Igra | 0x16d92794F5B81d2CdEd0F0958779A410401e6435 | |
KskdExit | Igra | 0x5A818D42cA142483f72AE7D426E199324FEc4871 | 18,880,615 |
KskdBridged (KSKD) | Robinhood Chain | 0x6ddb8541Af17DAdEA47dF41D1C13faf8Deee1902 | 84,780,073 |
KskdEntry | Robinhood Chain | 0x7b1D080d2485E63791468d73418e2B262c25C402 | 84,780,080 |
NitroAttestationVerifierV2 | Robinhood Chain | 0x52A22A678Af63d74BBfCB4c8fA90299db2760ACD |
Claims are signed by the bridge enclave key 0x7d3B23a631501a35cafe4F00133653A1b90f2CC3. Any account can submit a claim and pay its gas; usually the Kaskad relayer does.
Source code: kaskad-pontifex@1832366, compiled with solc 0.8.30. The deployed bytecode of KskdExit, KskdEntry and KskdBridged matches this commit. DESIGN.md in the same repository is the full specification.
| Network | Public RPC | Explorer |
|---|---|---|
| Igra | https://rpc.igralabs.com:8545 | explorer.igralabs.com |
| Robinhood Chain | https://rpc.mainnet.chain.robinhood.com | robinhoodchain.blockscout.com |
KskdExit (Igra)
Burns KSKD and records the running total per recipient. A burn cannot be undone.
| Function | Selector | Notes |
|---|---|---|
exit(uint256 amount, address recipient) | 0xcff40759 | Burns amount KSKD from the caller and adds it to burned[recipient]. Needs an allowance for KskdExit |
exitWithPermit(uint256 amount, address recipient, uint256 deadline, uint8 v, bytes32 r, bytes32 s) | 0x39fc55bc | Same, with the caller's EIP-2612 permit for exactly amount. A failed permit is ignored, so an existing allowance still works. The permit owner is the caller, so this call cannot be relayed |
burned(address recipient) → uint256 | 0xa7509b83 | Running total burned for recipient |
totalBurned() → uint256 | 0xd89135cd | Sum of all burns |
minBurn() → uint256 | 0xa47c9a2d | Minimum amount per call: 1,000 KSKD |
closed() → bool | 0x597e1fb5 | true when the contract no longer accepts burns. exit then reverts |
KSKD() → address | 0xa0abc4ef | The Igra KSKD token |
exit rejects three recipients: the zero address, KskdExit and KSKD. It accepts any other address without checks. KSKD credited to an address that nobody controls on Robinhood Chain is lost.
The KSKD permit domain on Igra is { name: "Kaskad", version: "1", chainId: 38833, verifyingContract: 0x16d9…6435 }.
| Event | topic0 |
|---|---|
Exited(address indexed sender, address indexed recipient, uint256 amount, uint256 cumulative) | 0xe1a7424e2aaaa1694a432707c2594818d282e12428245f4d3b3fff6b53c935ec |
Closed() | 0x1cdde67b72a90f19919ac732a437ac2f7a10fc128d28c2a6e525d89ce5cd9d3a |
| Error | Selector | Cause |
|---|---|---|
ZeroAmount() | 0x1f2a2005 | amount is 0 |
BelowMinBurn(uint256 amount, uint256 minBurn) | 0x8ab71a5b | amount is below minBurn |
InvalidRecipient(address recipient) | 0x17858bbe | recipient is the zero address, KskdExit or KSKD |
ExitClosed() | 0xf4e4f9df | The contract no longer accepts burns. Amounts burned before stay claimable |
A missing allowance or balance reverts in the KSKD token itself.
KskdEntry (Robinhood Chain)
Verifies claims signed by the bridge enclave, enforces the mint window, and tells KskdBridged to mint.
claim
function claim(address recipient, uint256 cumulativeBurned, uint256 deadline, bytes[] calldata signatures) external- Anyone can call it. The tokens always go to
recipient, who is part of the signed message. - It reverts with
ClaimExpiredonceblock.timestamp > deadline. - It needs at least
threshold()signatures, currently 1. Each must come from a registered signer, and they must be sorted by signer address in ascending order. - It mints
cumulativeBurned − bridgedIn[recipient]. If the window has less room, it mints what fits, and the same signature can complete the claim after the window rolls, until the deadline. - It reverts with
NothingToMintwhen nothing is owed, and withMintLimitExhaustedwhen the window is used up or minting is paused. - It emits
Claimed(recipient, minted, cumulative, attested).cumulative < attestedmarks a partial fill.
Views
| Function | Selector | Value today |
|---|---|---|
threshold() → uint256 | 0x42cde4e8 | 1 |
signerCount() → uint256 | 0x7ca548c6 | 1 |
validSigner(address) → bool | 0xba6f8b0e | true for 0x7d3B…2CC3 |
expectedPcr0() → bytes32 | 0x62776303 | 0x8761b33a…0d71, the first 32 bytes of the bridge image PCR0 |
mintLimit() → uint256 | 0x996517cf | 25,000,000 KSKD. 0 means paused |
windowStart() → uint256 | 0xb0c2783a | Start of the current window, unix seconds |
windowMinted() → uint256 | 0xe073f59b | Minted in the current window |
WINDOW() → uint256 | 0xa53e5412 | 86400 |
TOKEN() → address | 0x82bfefc8 | KskdBridged |
VERIFIER() → address | 0x08c84e70 | NitroAttestationVerifierV2 |
CLAIM_TYPEHASH() → bytes32 | 0x6b0509b1 | 0x9b616237…0661 |
eip712Domain() | 0x84b0196e | Pontifex, 1, 4663, KskdEntry (EIP-5267) |
The window is a fixed one-day window. It starts at the first claim after the previous window has ended, so up to two windows' worth (50,000,000 KSKD) can be minted within a short time across a boundary.
Events
| Event | topic0 |
|---|---|
Claimed(address indexed recipient, uint256 minted, uint256 cumulative, uint256 attested) | 0x9cdcf2f7714cca3508c7f0110b04a90a80a3a8dd0e35de99689db74d28c5383e |
Errors
| Error | Selector | Cause |
|---|---|---|
ClaimExpired(uint256 deadline) | 0xf5f3b230 | The claim is past its deadline. Request a new signature |
InsufficientSignatures(uint256 provided, uint256 required) | 0xf1e86aa6 | Fewer than threshold signatures |
UnsortedSigners() | 0xd02ef0e5 | Signers are not in ascending order, or a signer repeats |
InvalidSigner(address signer) | 0xbf18af43 | A signature recovers to an unregistered address. A tampered field gives this error too |
NothingToMint(uint256 cumulative, uint256 alreadyMinted) | 0xd668208c | cumulativeBurned is not above bridgedIn. Raised by KskdBridged |
MintLimitExhausted() | 0x22f08a6c | The window is used up, or minting is paused |
MaxSupplyExceeded(uint256 requested, uint256 available) | 0xea058246 | The mint would exceed MAX_SUPPLY. Raised by KskdBridged |
ECDSAInvalidSignature() | 0xf645eedf | Malformed signature (OpenZeppelin ECDSA) |
ECDSAInvalidSignatureLength(uint256 length) | 0xfce698f7 | Signature is not 65 bytes |
ECDSAInvalidSignatureS(bytes32 s) | 0xd78bce0c | High-s signature |
KskdBridged (Robinhood Chain)
The KSKD token on Robinhood Chain: ERC-20 with EIP-2612 permit, name Kaskad, symbol KSKD, 18 decimals. It has no premint and no burn function. Supply enters only through bridgeMint.
| Function | Selector | Notes |
|---|---|---|
bridgedIn(address recipient) → uint256 | 0x476fd840 | Running total minted to recipient |
bridge() → address | 0xe78cea92 | The only minter: KskdEntry |
MAX_SUPPLY() → uint256 | 0x32cb6b0c | 1,000,000,000 KSKD |
bridgeMint(address recipient, uint256 cumulative) | 0x8c2a993e | bridge only. Mints cumulative − bridgedIn[recipient] |
Errors: NotBridge() 0x7fea9dc5 when an address other than bridge calls bridgeMint, plus NothingToMint and MaxSupplyExceeded above.
The permit domain is { name: "Kaskad", version: "1", chainId: 4663, verifyingContract: 0x6ddb…1902 }.
Claim Signature
Claims are EIP-712 typed data:
const domain = {
name: "Pontifex",
version: "1",
chainId: 4663,
verifyingContract: "0x7b1D080d2485E63791468d73418e2B262c25C402", // KskdEntry
};
const types = {
Claim: [
{ name: "recipient", type: "address" },
{ name: "cumulativeBurned", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};- Type hash:
keccak256("Claim(address recipient,uint256 cumulativeBurned,uint256 deadline)")=0x9b616237eccc085bd93c7506319af683f69af351055bb1f4d2758018db390661. - Signature: 65 bytes,
r ‖ s ‖ v, low-s,vis 27 or 28. cumulativeBurnedisKskdExit.burned(recipient)read at the older of Igra'sfinalizedblock and head − 800. Today that is head − 800.deadlineis the earlier of the enclave clock and Robinhood Chain's latest block time, plus 3,600 seconds.
The domain binds the signature to KskdEntry on Robinhood Chain. It is not valid on any other contract or chain.
Bridge HTTP API
Base URL: https://nuntius.kaskad.live/bridge
The HTTP layer runs on the host instances, outside the enclave. A host can refuse or delay requests, but it cannot produce a valid signature: KskdEntry verifies every signature on-chain. Treat responses as untrusted until the contract accepts them.
Requests are rate-limited. On HTTP 429, wait a minute before retrying. Bridge responses carry no CORS headers, so a web page on another origin cannot read them; call the API from a server or a script.
GET /bridge/health
{
"state": "ready",
"signer": "0x7d3B23a631501a35cafe4F00133653A1b90f2CC3",
"version": "1",
"pcr0": "0x8761b33a338db4fab12c9201eef87e1c582310d59645848c2574061d1e070d71238976ea1af05ad4b4e645004621aac6",
"registered": false,
"igraFinalized": 19788720,
"rhFinalized": 94217640
}| Field | Type | Meaning |
|---|---|---|
state | string | ready when the enclave holds its signing key. fetching while it waits for the key |
signer | string | Address of the enclave's signing key. Empty before the key is installed |
version | string | Image version |
pcr0 | string | Full 48-byte PCR0 of the running image |
registered | boolean | Best-effort check of KskdEntry.validSigner(signer) at Robinhood Chain's finalized block. It reports false when that read fails. Call validSigner on KskdEntry instead |
igraFinalized | number | Igra block the enclave would sign at now. 0 on error |
rhFinalized | number | Robinhood Chain finalized block. 0 on error |
The status is 200 when the enclave reports a state and 503 otherwise.
GET /bridge/attestation
Query: nonce (optional) is a hex string of up to 512 bytes. The enclave places it in the signed document, which proves the document was produced after your request.
{ "attestation": "0x8444a101…", "signer": "0x7d3B23a631501a35cafe4F00133653A1b90f2CC3", "pcr0": "0x8761b33a…aac6" }attestation is the AWS Nitro attestation document (COSE_Sign1, hex). Its public_key is the signer key. Errors: 400 {"error":"bad_nonce"}, 503 when the enclave is unavailable. Enclave Verification shows how to check the document.
POST /bridge/sign
Returns a claim signature for a recipient's current signable total.
curl -s -X POST https://nuntius.kaskad.live/bridge/sign \
-H 'Content-Type: application/json' \
-d '{"recipient":"0xYourRecipientAddress"}'Send a JSON body of up to 4,096 bytes with a Content-Length header. Chunked bodies are rejected.
{
"signer": "0x7d3B23a631501a35cafe4F00133653A1b90f2CC3",
"signature": "0x…",
"cumulativeBurned": "50000000000000000000000",
"deadline": 1792479236,
"igraBlock": 19740001
}| Field | Type | Meaning |
|---|---|---|
signer | string | Enclave signer address |
signature | string | 65-byte EIP-712 signature over the Claim |
cumulativeBurned | string | burned(recipient) in wei, as a decimal string |
deadline | number | Unix seconds. claim reverts after it |
igraBlock | number | Igra block the total was read at |
cached | boolean | Present and true when the response came from the cache |
Grants are cached per recipient for 60 seconds. A burn appears in grants only after it is 800 blocks deep, about 14 minutes.
| Status | error | Meaning |
|---|---|---|
| 400 | bad_json, bad_recipient, bad_length | Malformed request |
| 408 | request_timeout | The request body did not arrive in time |
| 409 | nothing_burned | Nothing burned for this recipient at the signing depth yet |
| 409 | recipient_forbidden | The zero address, KskdExit or KSKD |
| 409 | not_ready | The enclave has no signing key yet |
| 409 | read_mismatch | Two Igra reads at the same block disagreed. Retry |
| 409 | rpc_error | An Igra or Robinhood Chain read failed. Retry |
| 409 | clock_skew | The enclave clock and Robinhood Chain time differ by more than 10 minutes |
| 409 | decreasing_burned | Igra returned a lower total than the enclave signed before. The enclave refuses to sign |
| 429 | rate_limited | Too many requests |
| 502 | enclave_unreachable, bad_enclave_response | The enclave is unavailable |
Examples
The scripts use viem and run with Node.js 22. The read-only scripts were run against mainnet on 21 October 2026. The scripts that send transactions were tested on local forks of both mainnets.
mkdir pontifex-scripts && cd pontifex-scripts
npm init -y && npm pkg set type=module
npm install viem@2.57.7 tsx@4.23.15Save the chain definitions as chains.ts. The other scripts import it.
// Chain definitions shared by the examples.
import { defineChain } from "viem";
export const igra = defineChain({
id: 38833,
name: "Igra",
nativeCurrency: { name: "iKAS", symbol: "iKAS", decimals: 18 },
rpcUrls: { default: { http: [process.env.IGRA_RPC ?? "https://rpc.igralabs.com:8545"] } },
});
export const robinhood = defineChain({
id: 4663,
name: "Robinhood Chain",
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
rpcUrls: { default: { http: [process.env.RH_RPC ?? "https://rpc.mainnet.chain.robinhood.com"] } },
});Read Transfer Status
// Print a recipient's bridge status and the current mint window, read from both chains.
import { createPublicClient, formatUnits, http, parseAbi, type Address } from "viem";
import { igra, robinhood } from "./chains.ts";
const KSKD_EXIT = "0x5A818D42cA142483f72AE7D426E199324FEc4871"; // Igra
const KSKD_ENTRY = "0x7b1D080d2485E63791468d73418e2B262c25C402"; // Robinhood Chain
const KSKD_BRIDGED = "0x6ddb8541Af17DAdEA47dF41D1C13faf8Deee1902"; // Robinhood Chain
const IGRA_DEPTH = 800n; // the enclave signs at the older of Igra's finalized block and head - 800
const exitAbi = parseAbi(["function burned(address recipient) view returns (uint256)"]);
const tokenAbi = parseAbi([
"function bridgedIn(address recipient) view returns (uint256)",
"function totalSupply() view returns (uint256)",
"function MAX_SUPPLY() view returns (uint256)",
]);
const entryAbi = parseAbi([
"function mintLimit() view returns (uint256)",
"function windowStart() view returns (uint256)",
"function windowMinted() view returns (uint256)",
"function WINDOW() view returns (uint256)",
]);
const recipient = process.argv[2] as Address | undefined;
if (!recipient) throw new Error("usage: npx tsx pontifex-status.ts <recipient>");
const igraClient = createPublicClient({ chain: igra, transport: http() });
const rhClient = createPublicClient({ chain: robinhood, transport: http() });
const [head, finalized] = await Promise.all([
igraClient.getBlockNumber(),
igraClient.getBlock({ blockTag: "finalized" }),
]);
const signBlock = finalized.number < head - IGRA_DEPTH ? finalized.number : head - IGRA_DEPTH;
const [burned, burnedSignable, received, supply, maxSupply, limit, start, minted, window, rhBlock] = await Promise.all([
igraClient.readContract({ address: KSKD_EXIT, abi: exitAbi, functionName: "burned", args: [recipient] }),
igraClient.readContract({ address: KSKD_EXIT, abi: exitAbi, functionName: "burned", args: [recipient], blockNumber: signBlock }),
rhClient.readContract({ address: KSKD_BRIDGED, abi: tokenAbi, functionName: "bridgedIn", args: [recipient] }),
rhClient.readContract({ address: KSKD_BRIDGED, abi: tokenAbi, functionName: "totalSupply" }),
rhClient.readContract({ address: KSKD_BRIDGED, abi: tokenAbi, functionName: "MAX_SUPPLY" }),
rhClient.readContract({ address: KSKD_ENTRY, abi: entryAbi, functionName: "mintLimit" }),
rhClient.readContract({ address: KSKD_ENTRY, abi: entryAbi, functionName: "windowStart" }),
rhClient.readContract({ address: KSKD_ENTRY, abi: entryAbi, functionName: "windowMinted" }),
rhClient.readContract({ address: KSKD_ENTRY, abi: entryAbi, functionName: "WINDOW" }),
rhClient.getBlock(),
]);
// The window rolls at the first claim at or after windowStart + WINDOW.
const rolled = rhBlock.timestamp >= start + window;
const windowRoom = limit > (rolled ? 0n : minted) ? limit - (rolled ? 0n : minted) : 0n;
const supplyRoom = maxSupply - supply;
const available = windowRoom < supplyRoom ? windowRoom : supplyRoom;
const kskd = (v: bigint) => `${formatUnits(v, 18)} KSKD`;
console.log(`burned on Igra: ${kskd(burned)}`);
console.log(`received on Robinhood Chain: ${kskd(received)}`);
console.log(`pending: ${kskd(burned - received)}`);
console.log(`signable now: ${kskd(burnedSignable > received ? burnedSignable - received : 0n)} (Igra block ${signBlock})`);
console.log(`bridge paused: ${limit === 0n}`);
console.log(`available this window: ${kskd(available)}`);
if (!rolled) console.log(`window resets no earlier than: ${new Date(Number(start + window) * 1000).toISOString()}`);$ npx tsx pontifex-status.ts 0x313807B857222278FbddF36541DE2Af5a50BdCB5
burned on Igra: 1000 KSKD
received on Robinhood Chain: 1000 KSKD
pending: 0 KSKD
signable now: 0 KSKD (Igra block 19788613)
bridge paused: false
available this window: 25000000 KSKDThe same reads with cast:
cast call 0x5A818D42cA142483f72AE7D426E199324FEc4871 "burned(address)(uint256)" $RECIPIENT --rpc-url https://rpc.igralabs.com:8545
cast call 0x6ddb8541Af17DAdEA47dF41D1C13faf8Deee1902 "bridgedIn(address)(uint256)" $RECIPIENT --rpc-url https://rpc.mainnet.chain.robinhood.comBridge From a Script
exit.ts burns KSKD on Igra. It uses approve and exit by default, or a single exitWithPermit transaction with USE_PERMIT=1.
RECIPIENT must be an address you control on Robinhood Chain. The script defaults to the sender's own address. Do not send to a smart-contract wallet unless the same wallet exists at that address on Robinhood Chain.
// Burn KSKD on Igra and credit a recipient on Robinhood Chain.
import { createPublicClient, createWalletClient, formatUnits, http, parseAbi, parseSignature, parseUnits, type Address, type Hex } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { igra } from "./chains.ts";
const KSKD = "0x16d92794F5B81d2CdEd0F0958779A410401e6435";
const KSKD_EXIT = "0x5A818D42cA142483f72AE7D426E199324FEc4871";
const exitAbi = parseAbi([
"function exit(uint256 amount, address recipient)",
"function exitWithPermit(uint256 amount, address recipient, uint256 deadline, uint8 v, bytes32 r, bytes32 s)",
"function minBurn() view returns (uint256)",
"function closed() view returns (bool)",
"function burned(address recipient) view returns (uint256)",
]);
const kskdAbi = parseAbi([
"function approve(address spender, uint256 amount) returns (bool)",
"function allowance(address owner, address spender) view returns (uint256)",
"function nonces(address owner) view returns (uint256)",
]);
const account = privateKeyToAccount(process.env.PRIVATE_KEY as Hex);
// Must be an address you control on Robinhood Chain. KSKD sent to any other address is lost.
const recipient = (process.env.RECIPIENT ?? account.address) as Address;
const amount = parseUnits(process.env.AMOUNT ?? "1000", 18);
const usePermit = process.env.USE_PERMIT === "1";
const publicClient = createPublicClient({ chain: igra, transport: http() });
const wallet = createWalletClient({ account, chain: igra, transport: http() });
const [minBurn, closed] = await Promise.all([
publicClient.readContract({ address: KSKD_EXIT, abi: exitAbi, functionName: "minBurn" }),
publicClient.readContract({ address: KSKD_EXIT, abi: exitAbi, functionName: "closed" }),
]);
if (closed) throw new Error("the migration is closed");
if (amount < minBurn) throw new Error(`minimum per exit is ${formatUnits(minBurn, 18)} KSKD`);
let hash: Hex;
if (usePermit) {
// One transaction: an EIP-2612 permit for exactly `amount`, consumed by exitWithPermit.
const deadline = BigInt(Math.floor(Date.now() / 1000) + 30 * 60);
const nonce = await publicClient.readContract({ address: KSKD, abi: kskdAbi, functionName: "nonces", args: [account.address] });
const signature = await wallet.signTypedData({
domain: { name: "Kaskad", version: "1", chainId: igra.id, verifyingContract: KSKD },
types: {
Permit: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
},
primaryType: "Permit",
message: { owner: account.address, spender: KSKD_EXIT, value: amount, nonce, deadline },
});
const { r, s, yParity } = parseSignature(signature);
hash = await wallet.writeContract({
address: KSKD_EXIT, abi: exitAbi, functionName: "exitWithPermit",
args: [amount, recipient, deadline, 27 + yParity, r, s],
});
} else {
// Two transactions: approve, then exit.
const allowance = await publicClient.readContract({ address: KSKD, abi: kskdAbi, functionName: "allowance", args: [account.address, KSKD_EXIT] });
if (allowance < amount) {
const approveHash = await wallet.writeContract({ address: KSKD, abi: kskdAbi, functionName: "approve", args: [KSKD_EXIT, amount] });
await publicClient.waitForTransactionReceipt({ hash: approveHash });
}
hash = await wallet.writeContract({ address: KSKD_EXIT, abi: exitAbi, functionName: "exit", args: [amount, recipient] });
}
const receipt = await publicClient.waitForTransactionReceipt({ hash });
const burned = await publicClient.readContract({ address: KSKD_EXIT, abi: exitAbi, functionName: "burned", args: [recipient] });
console.log(`exit ${receipt.status} in block ${receipt.blockNumber}; cumulative burned for ${recipient}: ${formatUnits(burned, 18)} KSKD`);PRIVATE_KEY=0x… AMOUNT=1000 npx tsx exit.ts
PRIVATE_KEY=0x… AMOUNT=1000 USE_PERMIT=1 npx tsx exit.tsThe sender needs KSKD and iKAS for gas on Igra. The relayer delivers the amount to Robinhood Chain; you do not need to do anything else.
Self-claim
Claim on Robinhood Chain yourself instead of waiting for the relayer, for example to collect a remainder under 1,000 KSKD. The script requests a signature from the bridge API and submits it. Any account with ETH on Robinhood Chain can pay the gas; the tokens go to the recipient in the signature.
// Request a claim signature from the bridge enclave and submit it to KskdEntry yourself.
import { createPublicClient, createWalletClient, formatUnits, http, parseAbi, type Address, type Hex } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { robinhood } from "./chains.ts";
const BRIDGE_API = process.env.BRIDGE_API ?? "https://nuntius.kaskad.live/bridge";
const KSKD_ENTRY = "0x7b1D080d2485E63791468d73418e2B262c25C402";
const KSKD_BRIDGED = "0x6ddb8541Af17DAdEA47dF41D1C13faf8Deee1902";
interface SignedClaim {
signer: Address;
signature: Hex;
cumulativeBurned: string; // decimal string, 18 decimals
deadline: number; // unix seconds
igraBlock: number; // Igra block the burn total was read at
cached?: boolean;
}
const entryAbi = parseAbi([
"function claim(address recipient, uint256 cumulativeBurned, uint256 deadline, bytes[] signatures)",
"function threshold() view returns (uint256)",
]);
const tokenAbi = parseAbi(["function bridgedIn(address recipient) view returns (uint256)"]);
const account = privateKeyToAccount(process.env.PRIVATE_KEY as Hex); // pays Robinhood Chain gas; need not be the recipient
const recipient = (process.env.RECIPIENT ?? account.address) as Address;
const res = await fetch(`${BRIDGE_API}/sign`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ recipient }),
});
const body = await res.json();
if (!res.ok) throw new Error(`bridge refused (${res.status}): ${body.error}`); // e.g. nothing_burned, not_ready
const grant = body as SignedClaim;
const publicClient = createPublicClient({ chain: robinhood, transport: http() });
const wallet = createWalletClient({ account, chain: robinhood, transport: http() });
const [threshold, bridgedIn] = await Promise.all([
publicClient.readContract({ address: KSKD_ENTRY, abi: entryAbi, functionName: "threshold" }),
publicClient.readContract({ address: KSKD_BRIDGED, abi: tokenAbi, functionName: "bridgedIn", args: [recipient] }),
]);
const cumulativeBurned = BigInt(grant.cumulativeBurned);
if (cumulativeBurned <= bridgedIn) throw new Error("nothing to claim: already minted");
if (threshold > 1n) throw new Error(`entry needs ${threshold} signatures; collect them sorted by signer address`);
const hash = await wallet.writeContract({
address: KSKD_ENTRY, abi: entryAbi, functionName: "claim",
args: [recipient, cumulativeBurned, BigInt(grant.deadline), [grant.signature]],
});
const receipt = await publicClient.waitForTransactionReceipt({ hash });
console.log(`claim ${receipt.status}; up to ${formatUnits(cumulativeBurned - bridgedIn, 18)} KSKD minted to ${recipient}`);PRIVATE_KEY=0x… RECIPIENT=0xYourRecipientAddress npx tsx self-claim.tsIf the relayer submits the same total first, your transaction reverts with NothingToMint and nothing is lost except gas. The Kaskad relayer also re-reads burned(recipient) on Igra at igraBlock before it submits; you can do the same check.
Verify a Claim
Recover the enclave signer of any past claim and check that it is registered:
// Recover the enclave signer of a KskdEntry.claim transaction and check it is registered.
import { createPublicClient, decodeFunctionData, http, parseAbi, recoverTypedDataAddress, type Hex } from "viem";
import { robinhood } from "./chains.ts";
const KSKD_ENTRY = "0x7b1D080d2485E63791468d73418e2B262c25C402";
const entryAbi = parseAbi([
"function claim(address recipient, uint256 cumulativeBurned, uint256 deadline, bytes[] signatures)",
"function validSigner(address signer) view returns (bool)",
]);
const txHash = process.argv[2] as Hex | undefined;
if (!txHash) throw new Error("usage: npx tsx verify-claim.ts <claim tx hash>");
const client = createPublicClient({ chain: robinhood, transport: http() });
const tx = await client.getTransaction({ hash: txHash });
if (tx.to?.toLowerCase() !== KSKD_ENTRY.toLowerCase()) throw new Error("not a direct call to KskdEntry");
const call = decodeFunctionData({ abi: entryAbi, data: tx.input });
if (call.functionName !== "claim") throw new Error("not a claim call");
const [recipient, cumulativeBurned, deadline, signatures] = call.args;
for (const signature of signatures) {
const signer = await recoverTypedDataAddress({
domain: { name: "Pontifex", version: "1", chainId: robinhood.id, verifyingContract: KSKD_ENTRY },
types: {
Claim: [
{ name: "recipient", type: "address" },
{ name: "cumulativeBurned", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
},
primaryType: "Claim",
message: { recipient, cumulativeBurned, deadline },
signature,
});
const registered = await client.readContract({ address: KSKD_ENTRY, abi: entryAbi, functionName: "validSigner", args: [signer] });
console.log({ recipient, cumulativeBurned, deadline: new Date(Number(deadline) * 1000).toISOString(), signer, registered });
}$ npx tsx verify-claim.ts 0xfd16d94972dbdbad093c5f9e455dea326300abc054b781a50613faa4849a70f7
{
recipient: '0x313807B857222278FbddF36541DE2Af5a50BdCB5',
cumulativeBurned: 1000000000000000000000n,
deadline: '2026-10-12T17:20:23.000Z',
signer: '0x7d3B23a631501a35cafe4F00133653A1b90f2CC3',
registered: true
}Index Transfers
Neither side needs an indexer: burned, bridgedIn, totalBurned and totalSupply answer most questions directly. For history, read the events. The Igra RPC caps eth_getLogs at 100,000 blocks per query.
# Burns on Igra
cast logs --address 0x5A818D42cA142483f72AE7D426E199324FEc4871 \
"Exited(address indexed sender, address indexed recipient, uint256 amount, uint256 cumulative)" \
--from-block 18880615 --to-block 18979615 --rpc-url https://rpc.igralabs.com:8545
# Mints on Robinhood Chain
cast logs --address 0x7b1D080d2485E63791468d73418e2B262c25C402 \
"Claimed(address indexed recipient, uint256 minted, uint256 cumulative, uint256 attested)" \
--from-block 84780080 --rpc-url https://rpc.mainnet.chain.robinhood.comA transfer is delivered when a Claimed event for its recipient has cumulative at least the transfer's Exited.cumulative.
Integration Notes
- Recipient. Use an address the user controls on Robinhood Chain. Smart-contract wallets usually do not exist at the same address on another chain.
- Minimum.
exitreverts belowminBurn, currently 1,000 KSKD. The Kaskad relayer only delivers a recipient whose pending amount is at least 1,000 KSKD. - Timing. A burn becomes signable 800 Igra blocks after its block, about 14 minutes. Igra block timestamps run about 10 minutes behind real time, and the gap drifts.
- Capacity. Before sending a large amount, check
mintLimit,windowStartandwindowMinted. Anything above the room left is delivered after the window rolls. - Health. Do not rely on
registeredin/bridge/health. ReadvalidSigner(signer)onKskdEntry. - Signatures expire after at most one hour. Request a new one rather than storing them.
Oracle API Reference
Signed Nuntius prices over HTTPS, the signature format, the on-chain oracle contracts, and submitting prices through KaskadRouter.
Verify the Enclaves
Check for yourself that the Nuntius oracle and the Pontifex bridge run the published code — rebuild the images, verify the live attestation, and match it to the on-chain pins.

