nora

Burn signing

Ask Nora to prepare the burn, have the token holder's wallet sign and broadcast it on Solana, Ethereum, Base or Polygon, and submit the proof back via /v2/intents/:id/approve-burn.

Creating an offramp intent leaves it in status: awaiting_burn_approval. To complete the offramp, your user signs the burn itself — there is no longer a standing delegate allowance.

The flow is a two-call handshake. You ask Nora to prepare the burn; Nora issues an attested on-chain burn grant for this exact intent, amount and holder. The holder's wallet signs and broadcasts the burn, and you submit the resulting proof back so Nora can verify the burn on-chain before paying out.

The two /v2 endpoints are:

POST /v2/intents/:id/prepare-burn   # issue the burn authorization
POST /v2/intents/:id/approve-burn   # submit the broadcast proof

The chain named by the intent's chainId decides what you sign and what you send back:

SolanaEVM (Ethereum, Base, Polygon)
tokenHolderbase58 wallet address0x address
What Nora preparesan unsigned user_burn transaction at metadata.userBurnTransactiona burn(...) call returned by prepare-burn itself (status: "ready")
Who broadcaststhe user's wallet, after signing the staged transactionthe holder, sending the call as-is
approve-burn prooftxSignaturetxHash

The sections through Gotchas describe Solana, except Request schemas, which covers both families. The EVM chains are covered in Ethereum below.

Flow

Loading diagram…

Steps

  1. Offramp intent has been created. You have an intentId and the intent's status is awaiting_burn_approval. See Offramp intent.

  2. Prepare the burn. POST /v2/intents/:id/prepare-burn with { tokenHolder }, where tokenHolder is the user's wallet (owner) address in base58 — the BRS holder who will sign. Nora issues an attested grant_burn on-chain and begins staging the unsigned user_burn transaction. The response is { status: "preparing" }. The grant has its own expiry, earlier than the intent's, exposed on the intent as metadata.burnGrantExpiresAt (Unix seconds); the staged transaction must be signed and land before it. An intent too close to its own expiresAt is refused with 400 Intent expires too soon to prepare a burn. Create a new intent and prepare the burn on it. Calling prepare-burn again with a different tokenHolder while a grant is live is not rejected: it answers preparing and the first holder stays bound. Read metadata.burnTokenHolder and hand the staged transaction only to that wallet.

  3. Poll for the staged transaction. GET /v2/intents/:id until metadata.userBurnTransaction is present — a base64-encoded unsigned user_burn transaction. (The reference client polls every ~1.5s for ~30s.)

  4. Client-side: sign + broadcast user_burn.

    • Decode the base64 into a Solana Transaction.
    • Refresh the blockhash (getLatestBlockhash('confirmed')) — the staged one may be stale by signing time — and set feePayer to the user's wallet. Refreshing only recentBlockhash and feePayer is safe: these are envelope fields, so the on-chain grant binding (bound to the transaction's instructions) stays valid. Don't reconstruct or reorder the instructions — see the Gotchas below.
    • Sign with the user's Solana wallet. This signs the burn itself, not a delegate approval.
    • Serialize and broadcast (sendRawTransaction); collect the txSignature, and wait for confirmed.
  5. Submit the signature. POST /v2/intents/:id/approve-burn with { txSignature, tokenHolder }. The call checks only that the signature is well formed and moves the intent to burn_approved; the on-chain verification (correct intent, holder, and amount) runs in the background before the payout. A signature that never lands, or a burn by another holder, fails the intent later with failureCode: "burn_failed" and a statusReason explaining why. Handle failed after a 200 here; do not treat the 200 as "payout guaranteed".

    Idempotency note. approve-burn accepts one proof per intent. After it succeeds the intent is burn_approved, and any further call answers 400 Intent is not awaiting burn approval; treat that as "already submitted". There is no separate idempotency-key header here — see Idempotency.

Where each call runs

X-API-Key is a server-side credential bound to your organization, never to a user, so the flow is split: your server calls prepare-burn, reads the intent and calls approve-burn; the user's browser or app only receives the staged transaction, signs it with the wallet and broadcasts it. Expose your own authenticated endpoints for the two hand-offs (staged transaction out, txSignature in) and check that the caller owns that intent. The outline below marks which side runs each step; the key-less fetch calls stand for calls to your own server.

Client outline

The library-agnostic shape is:

// 1. [server] Ask Nora to stage the burn.
await fetch(`/v2/intents/${intentId}/prepare-burn`, {
  method: 'POST',
  body: JSON.stringify({ tokenHolder: walletAddress }),
})

// 2. [server] Poll the intent until the unsigned user_burn tx is staged.
let userBurnBase64: string | undefined
const deadline = Date.now() + 30_000
while (Date.now() < deadline && !userBurnBase64) {
  const intent = await getIntent(intentId)
  userBurnBase64 = intent.metadata?.userBurnTransaction
  if (!userBurnBase64) await sleep(1500)
}

// 3. [browser] Decode, refresh the blockhash, set the fee payer, and sign the burn.
const tx = Transaction.from(Buffer.from(userBurnBase64, 'base64'))
const { blockhash, lastValidBlockHeight } =
  await connection.getLatestBlockhash('confirmed')
tx.recentBlockhash = blockhash
tx.lastValidBlockHeight = lastValidBlockHeight
tx.feePayer = walletPublicKey
const signed = await wallet.signTransaction(tx)

// 4. [browser] Broadcast and confirm; hand txSignature to your server.
const txSignature = await connection.sendRawTransaction(
  signed.serialize({ requireAllSignatures: false, verifySignatures: false }),
)
await connection.confirmTransaction(
  { blockhash, lastValidBlockHeight, signature: txSignature },
  'confirmed',
)

// 5. [server] Submit the signature back to Nora.
await fetch(`/v2/intents/${intentId}/approve-burn`, {
  method: 'POST',
  body: JSON.stringify({ txSignature, tokenHolder: walletAddress }),
})

The same calls with curl:

# Prepare
curl -X POST "https://sandbox.api.nora.finance/v2/intents/$INTENT_ID/prepare-burn" \
  -H "X-API-Key: $NORA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "tokenHolder": "Fg6P...<wallet address>" }'
# → { "status": "preparing" }

# ...poll GET /v2/intents/$INTENT_ID for metadata.userBurnTransaction, then
#    sign + broadcast client-side to obtain $TX_SIGNATURE...

# Approve
curl -X POST "https://sandbox.api.nora.finance/v2/intents/$INTENT_ID/approve-burn" \
  -H "X-API-Key: $NORA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "txSignature": "5h2...", "tokenHolder": "Fg6P...<wallet address>" }'
# → { "status": "burn_approved" }

Each response body is the single status field — no id, no echo of the intent. Re-fetch GET /v2/intents/:id to observe subsequent state transitions.

Request schemas

POST /v2/intents/:id/prepare-burn

Path parameter: id (uuid). Body:

FieldTypeRequiredNotes
tokenHolderstringYesThe user's wallet (owner) address that holds BRS and will sign the burn. Base58 on Solana, 0x on Ethereum; an address of the wrong family is a 400.

Response on Solana: { status: "preparing" }. The unsigned transaction appears asynchronously at metadata.userBurnTransaction (base64) — poll for it. On Ethereum the same call answers { status: "ready", burn } once the authorization exists — see Ethereum.

POST /v2/intents/:id/approve-burn

Path parameter: id (uuid). Body:

FieldTypeRequiredNotes
txSignaturestringSolanaBase58 signature of the broadcast user_burn transaction.
txHashstringEthereum0x plus 64 hex characters: the hash of the transaction that carried the burn.
tokenHolderstringYesThe same wallet (owner) address passed to prepare-burn.

Use the proof field of the intent's chain. The other chain's field name is tolerated and means the same thing, but the value must be well-formed for the intent's chain: a base58 signature on Solana, a 0x 32-byte hash on Ethereum. Anything else is a 400 such as txHash is not a valid transaction hash for chain family "evm".

Response: { status: "burn_approved" }. Errors follow the intents-family envelope { code, message, details? } (structural validation uses the validator shape, see Errors):

  • 400 — body validation, a proof that is not well formed for the intent's chain, or the intent is no longer awaiting_burn_approval. Nothing about the burn itself is checked here; that happens in the background after the 200 and surfaces as failed with a statusReason.
  • 404 — intent not found.

Recovery if the user reloads

The risky gap is between broadcast and submit: the burn may already be on-chain. Persist { intentId, tokenHolder, txSignature } in durable client storage as soon as sendRawTransaction returns. On resume, re-run POST /v2/intents/:id/approve-burn with the stored values; the user does not sign again. A 400 Intent is not awaiting burn approval means the earlier call had already succeeded: poll the intent. (The reference web client does exactly this via a small pendingCuiabaApproval record.)

Failure modes

  • Burned but not submitted. If the user_burn lands on Solana but the approve-burn call never succeeds, the BRS is already burned. Submitting the same txSignature is the recovery path — Nora verifies the burn on-chain and continues the payout. There is no standing delegate allowance left on the user's account to clean up (unlike the legacy approve-delegate flow).
  • Stale staged transaction. Always refresh the blockhash before signing; the blockhash staged by Nora can expire if the user takes a while to sign.
  • Burned, then the intent expired. If the user_burn landed but approve-burn was not accepted before the intent's expiresAt, the intent is expired and the BRS is gone. Do not create a new intent (that burns a second time); contact Nora with the intent id and the txSignature for manual reconciliation.

Gotchas

  • The transaction is staged for you. You do not build the burn instruction client-side anymore — decode and sign what Nora stages at metadata.userBurnTransaction. Do not reconstruct or reorder its instructions (the on-chain grant verification is bound to the staged transaction).
  • tokenHolder is the wallet owner, not the ATA. Pass the user's base58 wallet address — the same value used to sign.
  • User-rejected signatures should be treated distinctly. Wallet adapters throw with messages containing "rejected", "declined", "cancelled", or "user denied". Surface "try again" copy, not a generic error.
  • No idempotency-key header. approve-burn takes one proof per intent. Sending a header is not an error, but it is not used.

Ethereum

This section covers every EVM chain — Ethereum, Base and Polygon run the same calls, and only burn.chainRef differs.

On EVM nothing is staged on the intent. prepare-burn returns the burn(...) call itself, the holder sends it unchanged, and approve-burn takes the transaction hash.

Loading diagram…

Steps

  1. Prepare. POST /v2/intents/:id/prepare-burn with { tokenHolder: "0x…" }. The first answer is { "status": "preparing" }. Call it again a few seconds later with the same body:

    {
      "status": "ready",
      "burn": {
        "chainRef": "eip155:11155111",
        "from": "0xYourWallet000000000000000000000000000000",
        "to": "0x0000884179F857221EFC49C21BCb94af93CEB125",
        "data": "0xd03c72eb000000000000000000000000…",
        "amountMinor": "5000000",
        "expiresAt": 1788842399
      }
    }
    FieldMeaning
    chainRefCAIP-2 ref of the chain to send on: eip155:11155111 (Sepolia, sandbox) / eip155:1 (mainnet, production) for Ethereum; eip155:84532 (Base Sepolia, sandbox) / eip155:8453 (Base mainnet, production) for Base; eip155:80002 (Polygon Amoy, sandbox) / eip155:137 (Polygon mainnet, production) for Polygon. The chainId on the intent names the chain but not the network, so read this field.
    fromThe only address that may send it. The authorization binds the sender. tokenHolder must be all lower-case or a valid EIP-55 checksum; a wrong checksum is rejected as "not a valid address".
    toThe BRS token contract.
    dataABI-encoded burn(...) with Nora's attestation inside. Send it unchanged.
    amountMinorToken units burned, as a decimal string. "5000000" is 5 BRS.
    expiresAtUnix seconds. The contract rejects the call at or after this instant. It falls before the intent's own expiresAt, so the later you call prepare-burn, the less time the holder has.

    While an authorization is live, prepare-burn returns the same one and rejects a different tokenHolder with 400. A live authorization is never replaced before it expires, so the way out of a wrong holder is a new offramp intent. The same authorization also appears at metadata.evmBurnGrant on the intent.

  2. Send the call. From burn.from, send a transaction with to: burn.to, data: burn.data, value: 0. The BRS contract burns from the sender: the sender must be the holder you named, must be authorized on your whitelister contract, and must hold at least amountMinor. Nora does not check this before issuing; an unmet condition reverts on-chain. The holder pays the gas. A smart account or an EIP-7702 wrapper may wrap the call; Nora verifies the burn by the events the BRS contract emits, not by the outer transaction's to.

  3. Submit the hash. POST /v2/intents/:id/approve-burn with { txHash, tokenHolder }. The response is { "status": "burn_approved" }. The value must be the 0x 32-byte hash of the transaction; a base58 signature or a short value is a 400: txHash is not a valid transaction hash for chain family "evm".

  4. Track. GET /v2/intents/:id moves through burning and paying_out to completed. Verification waits for the chain's confirmation depth. metadata.burnTxSignature holds the hash once the burn is confirmed.

import {
  base,
  baseSepolia,
  mainnet,
  polygon,
  polygonAmoy,
  sepolia,
} from 'viem/chains'

// 1. [server] Prepare, then re-read until the authorization is ready.
//    The same server/browser split as on Solana applies: X-API-Key never
//    reaches the holder's wallet software.
const prepare = async () => {
  const res = await fetch(`${BASE_URL}/v2/intents/${intentId}/prepare-burn`, {
    method: 'POST',
    headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
    body: JSON.stringify({ tokenHolder }),
  })
  const body = await res.json()
  if (!res.ok) throw new Error(body.message)
  return body
}

let prepared = await prepare()
for (let attempt = 0; prepared.status !== 'ready'; attempt++) {
  // Issuance takes seconds. Still `preparing` after a handful of calls means
  // the intent is too close to its expiry to authorize: create a new one. If a
  // fresh intent behaves the same way, stop and contact Nora with the intent id.
  if (attempt >= 15) throw new Error('burn authorization not issued')
  await sleep(2000)
  prepared = await prepare()
}
const { from, to, data, chainRef } = prepared.burn

// 2. [holder's wallet / browser] Send the call unchanged (viem shown; any signer works).
//    `chain` makes viem refuse to broadcast if the wallet is connected to a
//    different network than the one `chainRef` names.
const CHAIN_BY_REF = {
  'eip155:1': mainnet,
  'eip155:11155111': sepolia,
  'eip155:8453': base,
  'eip155:84532': baseSepolia,
  'eip155:137': polygon,
  'eip155:80002': polygonAmoy,
} as const
const chain = CHAIN_BY_REF[chainRef as keyof typeof CHAIN_BY_REF]
if (!chain) throw new Error(`unsupported chainRef ${chainRef}`)
const txHash = await holderWalletClient.sendTransaction({
  account: from,
  to,
  data,
  value: 0n,
  chain,
})

// 3. [server] Hand the hash back. Persist { intentId, tokenHolder, txHash } first.
await fetch(`${BASE_URL}/v2/intents/${intentId}/approve-burn`, {
  method: 'POST',
  headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
  body: JSON.stringify({ txHash, tokenHolder }),
})

Ethereum gotchas

  • Burned, then the intent expired. If the burn landed but approve-burn was not accepted before the intent's expiresAt, the intent is expired and the BRS is gone. Do not create a new intent (that burns a second time); contact Nora with the intent id and the txHash.
  • Submit only the hash you actually broadcast. approve-burn checks the format and accepts the hash at once; verification runs in the background. Once the intent is burn_approved the endpoint rejects any other hash (Intent is not awaiting burn approval). A reverted burn fails the intent, and a failed intent cannot be re-approved through the API. A hash that never mines leaves the intent at burn_approved for Nora to reconcile by hand: contact Nora with the intent id.
  • Persist before you submit. Store { intentId, tokenHolder, txHash } as soon as the wallet returns the hash. If your app restarts before approve-burn succeeds, resend the same body. If approve-burn had already succeeded, the retry answers 400 and the intent is already progressing.
  • The authorization expires before the intent does, and is not replaced. By the time it lapses the intent is too close to its own expiry for a new one, so prepare-burn keeps answering preparing. If no burn was broadcast, create a new offramp intent; if one was, submit its hash with approve-burn (accepted until the intent expires) and, past that, contact Nora rather than burning again.
  • Do not edit data. The attestation covers the whitelister, the amount, a reference id and the expiry. Any change makes the contract reject it.

See also

On this page