nora

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 signature

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" }.

  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 intent advances to burn_approved, and Nora verifies the user_burn on-chain (correct intent, holder, and amount) before continuing the payout.

    Idempotency note. txSignature is the idempotency key for approve-burn. Resubmitting the same signature is accepted and returns the same result; a different signature against the same intent is rejected. There is no separate idempotency-key header 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:

FieldTypeRequiredNotes
tokenHolderstringYesThe 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:

FieldTypeRequiredNotes
txSignaturestringYesBase58 signature of the broadcast user_burn transaction. Min length 1.
tokenHolderstringYesThe 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_burn lands on Solana but the approve-burn call never succeeds, the BRS is already burned. Re-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.

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 dedups on txSignature. Sending a header is not an error, but it is not used.

See also

On this page