Kaskad

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.

TotalChainContractGrows when
burned[recipient]IgraKskdExitexit burns KSKD for recipient
bridgedIn[recipient]Robinhood ChainKskdBridgedKskdEntry.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 bridgedIn has caught up.
  • A newer signature covers every earlier burn, so a missed claim never has to be submitted.
Igra · 38833
KskdExit.exit(amount, recipient)
  • burned[recipient] += amount
  • emits Exited
800 blocks deep
AWS Nitro Enclave
POST /bridge/sign
  • reads burned[recipient] at min(finalized, head − 800)
  • signs an EIP-712 Claim
signature
Robinhood Chain · 4663
KskdEntry.claim(recipient, cumulativeBurned, deadline, signatures)
  • KskdBridged mints the difference
  • emits Claimed

Addresses

ContractNetworkAddressDeployed at block
KSKDIgra0x16d92794F5B81d2CdEd0F0958779A410401e6435
KskdExitIgra0x5A818D42cA142483f72AE7D426E199324FEc487118,880,615
KskdBridged (KSKD)Robinhood Chain0x6ddb8541Af17DAdEA47dF41D1C13faf8Deee190284,780,073
KskdEntryRobinhood Chain0x7b1D080d2485E63791468d73418e2B262c25C40284,780,080
NitroAttestationVerifierV2Robinhood Chain0x52A22A678Af63d74BBfCB4c8fA90299db2760ACD

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.

NetworkPublic RPCExplorer
Igrahttps://rpc.igralabs.com:8545explorer.igralabs.com
Robinhood Chainhttps://rpc.mainnet.chain.robinhood.comrobinhoodchain.blockscout.com

KskdExit (Igra)

Burns KSKD and records the running total per recipient. A burn cannot be undone.

FunctionSelectorNotes
exit(uint256 amount, address recipient)0xcff40759Burns 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)0x39fc55bcSame, 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) → uint2560xa7509b83Running total burned for recipient
totalBurned() → uint2560xd89135cdSum of all burns
minBurn() → uint2560xa47c9a2dMinimum amount per call: 1,000 KSKD
closed() → bool0x597e1fb5true when the contract no longer accepts burns. exit then reverts
KSKD() → address0xa0abc4efThe 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 }.

Eventtopic0
Exited(address indexed sender, address indexed recipient, uint256 amount, uint256 cumulative)0xe1a7424e2aaaa1694a432707c2594818d282e12428245f4d3b3fff6b53c935ec
Closed()0x1cdde67b72a90f19919ac732a437ac2f7a10fc128d28c2a6e525d89ce5cd9d3a
ErrorSelectorCause
ZeroAmount()0x1f2a2005amount is 0
BelowMinBurn(uint256 amount, uint256 minBurn)0x8ab71a5bamount is below minBurn
InvalidRecipient(address recipient)0x17858bberecipient is the zero address, KskdExit or KSKD
ExitClosed()0xf4e4f9dfThe 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 ClaimExpired once block.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 NothingToMint when nothing is owed, and with MintLimitExhausted when the window is used up or minting is paused.
  • It emits Claimed(recipient, minted, cumulative, attested). cumulative < attested marks a partial fill.

Views

FunctionSelectorValue today
threshold() → uint2560x42cde4e81
signerCount() → uint2560x7ca548c61
validSigner(address) → bool0xba6f8b0etrue for 0x7d3B…2CC3
expectedPcr0() → bytes320x627763030x8761b33a…0d71, the first 32 bytes of the bridge image PCR0
mintLimit() → uint2560x996517cf25,000,000 KSKD. 0 means paused
windowStart() → uint2560xb0c2783aStart of the current window, unix seconds
windowMinted() → uint2560xe073f59bMinted in the current window
WINDOW() → uint2560xa53e541286400
TOKEN() → address0x82bfefc8KskdBridged
VERIFIER() → address0x08c84e70NitroAttestationVerifierV2
CLAIM_TYPEHASH() → bytes320x6b0509b10x9b616237…0661
eip712Domain()0x84b0196ePontifex, 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

Eventtopic0
Claimed(address indexed recipient, uint256 minted, uint256 cumulative, uint256 attested)0x9cdcf2f7714cca3508c7f0110b04a90a80a3a8dd0e35de99689db74d28c5383e

Errors

ErrorSelectorCause
ClaimExpired(uint256 deadline)0xf5f3b230The claim is past its deadline. Request a new signature
InsufficientSignatures(uint256 provided, uint256 required)0xf1e86aa6Fewer than threshold signatures
UnsortedSigners()0xd02ef0e5Signers are not in ascending order, or a signer repeats
InvalidSigner(address signer)0xbf18af43A signature recovers to an unregistered address. A tampered field gives this error too
NothingToMint(uint256 cumulative, uint256 alreadyMinted)0xd668208ccumulativeBurned is not above bridgedIn. Raised by KskdBridged
MintLimitExhausted()0x22f08a6cThe window is used up, or minting is paused
MaxSupplyExceeded(uint256 requested, uint256 available)0xea058246The mint would exceed MAX_SUPPLY. Raised by KskdBridged
ECDSAInvalidSignature()0xf645eedfMalformed signature (OpenZeppelin ECDSA)
ECDSAInvalidSignatureLength(uint256 length)0xfce698f7Signature is not 65 bytes
ECDSAInvalidSignatureS(bytes32 s)0xd78bce0cHigh-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.

FunctionSelectorNotes
bridgedIn(address recipient) → uint2560x476fd840Running total minted to recipient
bridge() → address0xe78cea92The only minter: KskdEntry
MAX_SUPPLY() → uint2560x32cb6b0c1,000,000,000 KSKD
bridgeMint(address recipient, uint256 cumulative)0x8c2a993ebridge 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, v is 27 or 28.
  • cumulativeBurned is KskdExit.burned(recipient) read at the older of Igra's finalized block and head − 800. Today that is head − 800.
  • deadline is 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
}
FieldTypeMeaning
statestringready when the enclave holds its signing key. fetching while it waits for the key
signerstringAddress of the enclave's signing key. Empty before the key is installed
versionstringImage version
pcr0stringFull 48-byte PCR0 of the running image
registeredbooleanBest-effort check of KskdEntry.validSigner(signer) at Robinhood Chain's finalized block. It reports false when that read fails. Call validSigner on KskdEntry instead
igraFinalizednumberIgra block the enclave would sign at now. 0 on error
rhFinalizednumberRobinhood 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
}
FieldTypeMeaning
signerstringEnclave signer address
signaturestring65-byte EIP-712 signature over the Claim
cumulativeBurnedstringburned(recipient) in wei, as a decimal string
deadlinenumberUnix seconds. claim reverts after it
igraBlocknumberIgra block the total was read at
cachedbooleanPresent 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.

StatuserrorMeaning
400bad_json, bad_recipient, bad_lengthMalformed request
408request_timeoutThe request body did not arrive in time
409nothing_burnedNothing burned for this recipient at the signing depth yet
409recipient_forbiddenThe zero address, KskdExit or KSKD
409not_readyThe enclave has no signing key yet
409read_mismatchTwo Igra reads at the same block disagreed. Retry
409rpc_errorAn Igra or Robinhood Chain read failed. Retry
409clock_skewThe enclave clock and Robinhood Chain time differ by more than 10 minutes
409decreasing_burnedIgra returned a lower total than the enclave signed before. The enclave refuses to sign
429rate_limitedToo many requests
502enclave_unreachable, bad_enclave_responseThe 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.15

Save the chain definitions as chains.ts. The other scripts import it.

chains.ts
// 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

pontifex-status.ts
// 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 KSKD

The 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.com

Bridge 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.

exit.ts
// 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.ts

The 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.

self-claim.ts
// 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.ts

If 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:

verify-claim.ts
// 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.com

A 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. exit reverts below minBurn, 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, windowStart and windowMinted. Anything above the room left is delivered after the window rolls.
  • Health. Do not rely on registered in /bridge/health. Read validSigner(signer) on KskdEntry.
  • Signatures expire after at most one hour. Request a new one rather than storing them.