Burn signing
Ask Nora to stage an unsigned user_burn transaction, have the user's wallet sign it, broadcast it on Solana, and submit the signature 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 and stages an unsigned user_burn
transaction on the intent. You poll for it, the user's wallet signs it, you
broadcast it on Solana, and you submit the resulting txSignature back so Nora
can verify the burn on-chain before paying out.
The two /v2 endpoints are:
POST /v2/intents/:id/prepare-burn # stage the unsigned user_burn tx
POST /v2/intents/:id/approve-burn # submit the broadcast signatureFlow
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" }. -
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 intent advances toburn_approved, and Nora verifies theuser_burnon-chain (correct intent, holder, and amount) before continuing the payout.Idempotency note.
txSignatureis the idempotency key forapprove-burn. Resubmitting the same signature is accepted and returns the same result; a different signature against the same intent is rejected. There is no separateidempotency-keyheader here — see Idempotency.
Client outline
The library-agnostic shape is:
// 1. Ask Nora to stage the burn.
await fetch(`/v2/intents/${intentId}/prepare-burn`, {
method: 'POST',
body: JSON.stringify({ tokenHolder: walletAddress }),
})
// 2. 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. 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. Broadcast and confirm.
const txSignature = await connection.sendRawTransaction(
signed.serialize({ requireAllSignatures: false, verifySignatures: false }),
)
await connection.confirmTransaction(
{ blockhash, lastValidBlockHeight, signature: txSignature },
'confirmed',
)
// 5. 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, 32–44 chars. |
Response: { status: "preparing" }. The unsigned transaction appears
asynchronously at metadata.userBurnTransaction (base64) — poll for it.
POST /v2/intents/:id/approve-burn
Path parameter: id (uuid). Body:
| Field | Type | Required | Notes |
|---|---|---|---|
txSignature | string | Yes | Base58 signature of the broadcast user_burn transaction. Min length 1. |
tokenHolder | string | Yes | The same wallet (owner) address passed to prepare-burn. 32–44 chars. |
Response: { status: "burn_approved" }. Errors follow the intents-family
envelope { code, message, validationErrors?, details? }:
400— body validation, signature rejected, wrong signer / amount, or the on-chain burn does not match the intent.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 server dedups
on txSignature, so the user does not need to sign again. (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. Re-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.
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-burndedups ontxSignature. Sending a header is not an error, but it is not used.
See also
- Offramp intent — the prerequisite
- API Reference: Intents — endpoint spec for
prepare-burnandapprove-burn - Idempotency —
txSignatureas the dedup key