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 proofThe chain named by the intent's chainId decides what you sign and what
you send back:
| Solana | EVM (Ethereum, Base, Polygon) | |
|---|---|---|
tokenHolder | base58 wallet address | 0x address |
| What Nora prepares | an unsigned user_burn transaction at metadata.userBurnTransaction | a burn(...) call returned by prepare-burn itself (status: "ready") |
| Who broadcasts | the user's wallet, after signing the staged transaction | the holder, sending the call as-is |
approve-burn proof | txSignature | txHash |
The sections through Gotchas describe Solana, except Request schemas, which covers both families. The EVM chains are covered in Ethereum below.
Flow
Steps
-
Offramp intent has been created. You have an
intentIdand the intent's status isawaiting_burn_approval. See Offramp intent. -
Prepare the burn.
POST /v2/intents/:id/prepare-burnwith{ tokenHolder }, wheretokenHolderis the user's wallet (owner) address in base58 — the BRS holder who will sign. Nora issues an attestedgrant_burnon-chain and begins staging the unsigneduser_burntransaction. The response is{ status: "preparing" }. The grant has its own expiry, earlier than the intent's, exposed on the intent asmetadata.burnGrantExpiresAt(Unix seconds); the staged transaction must be signed and land before it. An intent too close to its ownexpiresAtis refused with400 Intent expires too soon to prepare a burn. Create a new intent and prepare the burn on it.Callingprepare-burnagain with a differenttokenHolderwhile a grant is live is not rejected: it answerspreparingand the first holder stays bound. Readmetadata.burnTokenHolderand hand the staged transaction only to that wallet. -
Poll for the staged transaction.
GET /v2/intents/:iduntilmetadata.userBurnTransactionis present — a base64-encoded unsigneduser_burntransaction. (The reference client polls every ~1.5s for ~30s.) -
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 setfeePayerto the user's wallet. Refreshing onlyrecentBlockhashandfeePayeris 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 thetxSignature, and wait forconfirmed.
- Decode the base64 into a Solana
-
Submit the signature.
POST /v2/intents/:id/approve-burnwith{ txSignature, tokenHolder }. The call checks only that the signature is well formed and moves the intent toburn_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 withfailureCode: "burn_failed"and astatusReasonexplaining why. Handlefailedafter a200here; do not treat the200as "payout guaranteed".Idempotency note.
approve-burnaccepts one proof per intent. After it succeeds the intent isburn_approved, and any further call answers400 Intent is not awaiting burn approval; treat that as "already submitted". There is no separateidempotency-keyheader 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:
| Field | Type | Required | Notes |
|---|---|---|---|
tokenHolder | string | Yes | The 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:
| Field | Type | Required | Notes |
|---|---|---|---|
txSignature | string | Solana | Base58 signature of the broadcast user_burn transaction. |
txHash | string | Ethereum | 0x plus 64 hex characters: the hash of the transaction that carried the burn. |
tokenHolder | string | Yes | The 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 longerawaiting_burn_approval. Nothing about the burn itself is checked here; that happens in the background after the200and surfaces asfailedwith astatusReason.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_burnlands on Solana but theapprove-burncall never succeeds, the BRS is already burned. Submitting the sametxSignatureis 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_burnlanded butapprove-burnwas not accepted before the intent'sexpiresAt, the intent isexpiredand the BRS is gone. Do not create a new intent (that burns a second time); contact Nora with the intent id and thetxSignaturefor 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). tokenHolderis 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-keyheader.approve-burntakes 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.
Steps
-
Prepare.
POST /v2/intents/:id/prepare-burnwith{ 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 } }Field Meaning 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. ThechainIdon 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. tokenHoldermust 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 callprepare-burn, the less time the holder has.While an authorization is live,
prepare-burnreturns the same one and rejects a differenttokenHolderwith400. 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 atmetadata.evmBurnGranton the intent. -
Send the call. From
burn.from, send a transaction withto: 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 leastamountMinor. 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'sto. -
Submit the hash.
POST /v2/intents/:id/approve-burnwith{ txHash, tokenHolder }. The response is{ "status": "burn_approved" }. The value must be the0x32-byte hash of the transaction; a base58 signature or a short value is a400:txHash is not a valid transaction hash for chain family "evm". -
Track.
GET /v2/intents/:idmoves throughburningandpaying_outtocompleted. Verification waits for the chain's confirmation depth.metadata.burnTxSignatureholds 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-burnwas not accepted before the intent'sexpiresAt, the intent isexpiredand the BRS is gone. Do not create a new intent (that burns a second time); contact Nora with the intent id and thetxHash. - Submit only the hash you actually broadcast.
approve-burnchecks the format and accepts the hash at once; verification runs in the background. Once the intent isburn_approvedthe 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 atburn_approvedfor 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 beforeapprove-burnsucceeds, resend the same body. Ifapprove-burnhad already succeeded, the retry answers400and 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-burnkeeps answeringpreparing. If no burn was broadcast, create a new offramp intent; if one was, submit its hash withapprove-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
- Ethereum end-to-end — the same calls inside the full Ethereum flow
- Offramp intent — the prerequisite
- API Reference: Intents — endpoint spec for
prepare-burnandapprove-burn - Idempotency — one proof per intent, and how to retry
Offramp intent
Redeem BRS tokens for a BRL payout via PIX. Your backend creates the intent, the token holder burns on Solana, Ethereum, Base or Polygon, and the payout settles.
Ethereum end-to-end
Whitelist a wallet, mint BRS by PIX, and burn it back to BRL on Ethereum. Every request and response below is what the sandbox returns today.