Wallet whitelisting
Register a minter wallet via /v2/wallets, sign the grant yourself, and activate it. Three paths - prepared, self-built, or EVM calldata.
A wallet needs an on-chain whitelist grant before an intent can mint to it or burn from it. This flow registers a minter wallet and activates it. You sign the grant and pay the fee. Nora does not sign routine grants.
Paths A and B are Solana. Path C is the EVM path — jump
straight there if chainId is "ethereum", "base" or "polygon";
nothing in A or B applies.
Prerequisites (solana)
BRS on Solana is an SPL token with 6 decimals:
| Environment | Cluster | Mint |
|---|---|---|
| Sandbox | devnet | BRS2CELW6Cueo2mrMUVvAr5GDT7Pw8TeostC2JLMpBk4 |
| Production | mainnet | BRSxQRUaGswjLs7ewcH7uXj3r7SgmfKSSLLXyCKHZtUo |
Explorer links and the EVM addresses are on the Addresses page.
- An active whitelister wallet registered for your organization on the dashboard. The API does not register whitelisters.
- The whitelister keypair available to your server, with SOL for rent and fees (~0.002 SOL per wallet).
- An API key. See Authentication.
Path A — prepared transaction (solana)
- Register.
POST /v2/walletswith{ address, whitelisterWallet }and anIdempotency-Key. The response stages a base64 unsigned grant transaction. ItsfeePayeris your whitelister. - Sign. Decode the base64. Sign with the whitelister keypair. Do not change the fee payer, the blockhash, or the instructions. Nora verifies the bytes against the preparation before it broadcasts.
- Submit.
POST /v2/wallets/:id/submitwith{ signedTransaction }and a newIdempotency-Key. Nora broadcasts, confirms, and activates. - Confirm.
GET /v2/wallets/:idreturnsstate: "active".
Expiry. The preparation expires with its blockhash (expiresAt,
lastValidBlockHeight). When details.code is TRANSACTION_EXPIRED, call
POST /v2/wallets/:id/prepare, re-sign the fresh transaction, and submit
again. While the current preparation is valid, prepare returns the same
one.
Path B — compose your own transaction (solana)
The POST /v2/wallets response gives you the grant instruction, decomposed.
You compose it into your own transaction and broadcast it. You do not
assemble the byte layout by hand.
Use this path to add your own ComputeBudget, use a durable nonce, or batch the grant with other instructions.
Steps
- Register. Call
POST /v2/wallets. For a new registration, the response holds the pending wallet andtransaction.instruction— the grant asprogramId,keys, and base64data. - Rebuild the instruction from that field.
- Compose the transaction. Add the instruction. Set the fee payer to your whitelister wallet. Set a recent blockhash or a durable nonce.
- Sign with your whitelister keypair and broadcast the transaction.
- Sync with proof. Call
POST /v2/wallets/:id/syncwith thetxSignature. Nora reads the on-chainWhitelistUpdatedEvent. The grant must come from one of your active whitelisters. The response is{ outcome: "activated" }.
import {
Connection,
PublicKey,
Transaction,
TransactionInstruction,
} from "@solana/web3.js";
// 1. Register — the response carries the decomposed instruction.
const created = await fetch(`${BASE_URL}/v2/wallets`, {
method: "POST",
headers: {
"X-API-Key": apiKey,
"Idempotency-Key": crypto.randomUUID(),
"Content-Type": "application/json",
},
body: JSON.stringify({
address,
whitelisterWallet: whitelister.publicKey.toBase58(),
}),
}).then((r) => r.json());
// 2. A new registration returns a composable instruction. A wallet that is
// already granted can return null instead; sync it with the original signature.
const instruction = created.transaction?.instruction;
if (!instruction) {
throw new Error(
"No composable instruction was returned. If the grant already exists, call sync with its txSignature.",
);
}
const ix = new TransactionInstruction({
programId: new PublicKey(instruction.programId),
keys: instruction.keys.map((k) => ({
pubkey: new PublicKey(k.pubkey),
isSigner: k.isSigner,
isWritable: k.isWritable,
})),
data: Buffer.from(instruction.data, "base64"),
});
// 3. Compose the transaction. The whitelister is the fee payer and the signer.
const { blockhash } = await connection.getLatestBlockhash("confirmed");
const tx = new Transaction({
feePayer: whitelister.publicKey,
recentBlockhash: blockhash,
}).add(ix);
// Add your own ComputeBudget instructions here if you need them.
// 4. Sign and broadcast.
tx.sign(whitelister);
const txSignature = await connection.sendRawTransaction(tx.serialize());
await connection.confirmTransaction(txSignature, "confirmed");
// 5. Sync with proof.
const synced = await fetch(`${BASE_URL}/v2/wallets/${created.wallet.id}/sync`, {
method: "POST",
headers: { "X-API-Key": apiKey, "Content-Type": "application/json" },
body: JSON.stringify({ txSignature }),
}).then((r) => r.json());
// synced.outcome === "activated"sync with txSignature is required for this self-built path. Without it,
sync cannot attribute a grant it sees on-chain: it returns 422 with
details.code of SYNC_PROOF_REQUIRED and the wallet stays pending with
statusReason: "awaiting_sync_proof". Before your transaction lands, the
same call is a 200 with outcome: "not_on_chain".
Path A's preparation is valid only for the lifetime of its blockhash: well
under the time a human wallet prompt or a multisig round-trip takes. Call
prepare immediately before signing, and expect TRANSACTION_EXPIRED (or a
400 saying there is no pending preparation) if you did not.
The background reconciler can activate a transaction that structurally matches Nora's full staged transaction. A self-built transaction normally does not match, because it can use another blockhash, a durable nonce, or extra instructions.
Reference: raw instruction layout
Prefer transaction.instruction from the API (Path B above) — it is always
current. The layout below serves audits and clients that construct the
instruction with no API call. Nora's E2E suite builds the instruction from
these values and verifies them against both the API response and the
program on every run.
Program IDs
| Environment | Cluster | Program ID |
|---|---|---|
| Sandbox | devnet | 9fBSeVHUCaHHUzkktiRp5Yn35emxx3S1ERzn7oHsi8je |
| Production | mainnet | noRAkuzHAdMjsbj2P9gNPMm94hui54NLkBDXe6uasVt |
Accounts (in this order)
| # | Account | Signer | Writable | Derivation |
|---|---|---|---|---|
| 1 | whitelister | Yes | Yes | Your whitelister wallet. Also the fee payer. |
| 2 | whitelister_pda | No | No | PDA: seeds ["whitelist", whitelister] |
| 3 | user | No | No | The wallet to whitelist. |
| 4 | whitelist_pda | No | Yes | PDA: seeds ["whitelist", user] |
| 5 | system_program | No | No | 11111111111111111111111111111111 |
Instruction data: 9 bytes. The 8-byte Anchor discriminator
[0, 143, 193, 93, 69, 29, 183, 140], then 1 byte for the
WhitelistStatus enum. Use 2 (Minter) — the API activates no other
value.
import {
Connection, PublicKey, SystemProgram,
Transaction, TransactionInstruction,
} from "@solana/web3.js";
const PROGRAM_ID = new PublicKey("..."); // see the table above
const DISCRIMINATOR = Buffer.from([0, 143, 193, 93, 69, 29, 183, 140]);
const MINTER = 2;
const [whitelisterPda] = PublicKey.findProgramAddressSync(
[Buffer.from("whitelist"), whitelister.publicKey.toBuffer()], PROGRAM_ID);
const [whitelistPda] = PublicKey.findProgramAddressSync(
[Buffer.from("whitelist"), user.toBuffer()], PROGRAM_ID);
const ix = new TransactionInstruction({
programId: PROGRAM_ID,
keys: [
{ pubkey: whitelister.publicKey, isSigner: true, isWritable: true },
{ pubkey: whitelisterPda, isSigner: false, isWritable: false },
{ pubkey: user, isSigner: false, isWritable: false },
{ pubkey: whitelistPda, isSigner: false, isWritable: true },
{ pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
],
data: Buffer.concat([DISCRIMINATOR, Buffer.from([MINTER])]),
});
const { blockhash, lastValidBlockHeight } =
await connection.getLatestBlockhash("confirmed");
const tx = new Transaction({
feePayer: whitelister.publicKey, blockhash, lastValidBlockHeight,
}).add(ix);
tx.sign(whitelister);
const txSignature = await connection.sendRawTransaction(tx.serialize());On-chain constraints. The signer must hold Whitelister status. The
target wallet must have no prior grant (None); the program rejects
re-grants from a whitelister. The transaction pays rent for the new PDA
(~0.001 SOL) plus the fee.
Path C — EVM calldata (Ethereum, Base, Polygon)
On an EVM chain there is no unsigned transaction and no whitelisterWallet.
Nora returns calldata for your organization's own BRSWhitelister contract;
the contract's owner wallet sends it, and you hand back the resulting
txHash.
Everything below applies unchanged to "ethereum", "base" and "polygon"
— the same BRSWhitelister flow runs on each, and Nora resolves that chain's
BRS token and your whitelister contract from chainId. chainId names the
chain, not the network: the calldata is valid only on calldata.chainRef, so
resolve the network you broadcast on from that field and never from the
chainId you sent.
Prerequisites (evm)
- A BRSWhitelister contract provisioned by Nora for your organization. Until
it exists,
POST /v2/walletsreturns409withdetails.codeofWHITELISTER_NOT_PROVISIONED. - The contract's owner wallet available to your server, funded for gas. Nora never holds that key.
- An API key.
Steps
-
Register.
POST /v2/walletswith{ chainId: "ethereum", address, limitMinor }and anIdempotency-Key.limitMinoris the requested daily limit in BRL cents —100000is R$1.000,00, not token units. Do not sendwhitelisterWallet: the body is strict on this arm and the field is a400. -
Send the calldata. The response carries
wallet.calldata(to,data,chainRef,signerWallet,limitMinor,expiresAt) andtransaction: null. Send{ to, data, value: 0 }fromsignerWalleton the chain named bychainRef— not on whatever network your wallet happens to be connected to. The window is 24 hours.calldataisnullwhen the whitelister already authorizes the address: the wallet comes backactiveat its on-chain limit, and steps 2–5 do not run. Branch onwallet.calldata, not onstate. -
Submit the hash.
POST /v2/wallets/:id/submitwith{ txHash }and a newIdempotency-Key. The field istxHash— notsignedTransactionand nottxSignature. Nora reads the receipt and verifies the emitted grant against the prepared recipient and limit. -
Handle
RECEIPT_PENDING. While the receipt is unconfirmed, submit returns409withdetails.code: RECEIPT_PENDINGand aRetry-Afterheader. Retry with the sametxHashand the sameIdempotency-Key. RespectRetry-After— this route has a tighter per-key budget than the global limit. -
Confirm.
GET /v2/wallets/:idreturnsstate: "active"with the effectivelimitMinor.
import {
base,
baseSepolia,
mainnet,
polygon,
polygonAmoy,
sepolia,
} from "viem/chains";
// 1. Register.
const chainId: "ethereum" | "base" | "polygon" = "ethereum";
const created = await fetch(`${BASE_URL}/v2/wallets`, {
method: "POST",
headers: {
"X-API-Key": apiKey,
"Idempotency-Key": crypto.randomUUID(),
"Content-Type": "application/json",
},
body: JSON.stringify({
chainId,
address, // 0x… the wallet to whitelist as minter
limitMinor: 100000, // R$1.000,00 per day, in BRL cents
}),
}).then((r) => r.json());
// 2. Send the calldata from the whitelister contract's owner, on the network
// `chainRef` names — `chainId` does not identify it. A `null` calldata
// means the address is already authorized: the wallet is `active`, stop.
const { to, data, chainRef, signerWallet } = created.wallet.calldata;
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 ownerWalletClient.sendTransaction({
account: signerWallet,
to,
data,
value: 0n,
chain, // switches/pins the wallet to the calldata's network
});
// 3. Submit the hash; retry the SAME hash + key on RECEIPT_PENDING.
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const submitKey = crypto.randomUUID();
for (;;) {
const res = await fetch(`${BASE_URL}/v2/wallets/${created.wallet.id}/submit`, {
method: "POST",
headers: {
"X-API-Key": apiKey,
"Idempotency-Key": submitKey,
"Content-Type": "application/json",
},
body: JSON.stringify({ txHash }),
});
if (res.ok) break;
const body = await res.json();
const code = body.details?.code;
if (code === "OPERATION_UNBOUND") {
// Re-prepare, then submit against the fresh calldata.
throw new Error("re-prepare: " + body.message);
}
if (code !== "RECEIPT_PENDING") throw new Error(body.message);
await sleep(Number(res.headers.get("Retry-After") ?? 3) * 1000);
}The limit is clamped, and that is success
The contract clamps your requested limit to your organization's on-chain
ceiling. The wallet still becomes active; it just reports the lower,
effective value plus a reason:
{
"state": "active",
"limitMinor": 100000000,
"statusReason": "limit_clamped: requested 200000000, effective 100000000 (the whitelister's on-chain ceiling)"
}Do not treat a clamp as a failure and do not retry it. If you need a higher ceiling, contact Nora.
An active wallet's limit is fixed. No /v2 endpoint can change it.
prepare on an active wallet returns 200 with calldata: null — a silent
no-op, not an error. Raising an active wallet's limit requires Nora support.
Cancel, revoke, repair
- Cancel a pending registration.
DELETE /v2/wallets/:idhard-deletes the row and frees the address:GETafterwards is404, and you can register the same address again from scratch. On an active wallet this is a409. - A revoke is not instant. If the authorization is zeroed out-of-band, the
first
POST /v2/wallets/:id/syncreportsoutcome: "not_on_chain"while the state staysactive. The row flips torevokedonly once the zero is confirmed at the chain's confirmation depth. - Repair a revoked wallet.
POST /v2/wallets/:id/prepareissues fresh positive calldata for the last limit the wallet requested. Send it, callsubmitwith the newtxHash, and the wallet returns toactive. There is no need to re-register.
Reference: addresses, limits and owners
Ethereum, Base and Polygon are live in both environments. The BRS contract
address, chainRef and explorer for each network are on the
Addresses page; each chain's own
chainRef also comes back on wallet.calldata. Your organization gets a
BRSWhitelister contract provisioned by Nora; its owner() is the address
you give us (an EOA or a Safe). Only that owner can authorize recipients, and
only authorized recipients can receive mints or submit burns.
BRS has 6 decimals on every EVM chain, the same as on Solana. Per-mint limits,
your whitelister's daily cap (agreed at onboarding) and a network-wide daily
cap shared by all partners are on-chain settings that can change. The API
rejects before charging, with a 400, an amount outside the per-mint
bounds (evm_mint_amount_below_minimum, evm_mint_amount_above_maximum)
or above the wallet's configured daily limit
(evm_destination_wallet_rate_limit_exceeded), and reports a clamped
wallet limit in statusReason. It cannot check how much of a daily cap is
already used today; a mint over the remaining budget fails after the
charge and goes to manual reconciliation.
How a grant works. POST /v2/wallets returns calldata for
setAuthorizedRecipient(recipient, limit) on your whitelister. The owner
signs and broadcasts it, then you call submit with the transaction hash.
Nora verifies the receipt by the event your whitelister emitted, so the
transaction may be wrapped by a smart-account wallet or a relayer.
If your owner is a contract wallet (a Safe, a smart account): both paths
work. submit accepts the wrapped transaction's hash, because Nora verifies
the event your whitelister emitted, not who sent the transaction. If you do
not have the hash at hand (for example the grant was executed from the Safe
UI), call POST /v2/wallets/:id/sync: it reads the authorization straight
from your whitelister and activates the wallet.
Gas. The owner pays gas for each grant, and each recipient pays gas for its own burns. Mints cost you nothing on-chain: Nora's minter pays.
Failure modes
| Symptom | Chain | Cause | Recovery |
|---|---|---|---|
details.code: TRANSACTION_EXPIRED on submit | solana | The blockhash expired before signing | prepare → re-sign → submit |
details.code: TRANSACTION_MISMATCH on submit | solana | The bytes differ from the preparation | Re-sign the staged transaction without changes |
details.code: SYNC_PROOF_REQUIRED | solana | Grant observed, authorship unproven | sync with the grant's txSignature |
details.code: WHITELISTER_NOT_ACTIVE | solana | The whitelister is revoked or pending | Fix the whitelister on the dashboard first |
details.code: WHITELISTER_NOT_PROVISIONED | evm | No whitelister contract exists for your org | Contact Nora |
400 naming whitelisterWallet | evm | The field was sent on the EVM arm | Drop it — Nora resolves the contract server-side |
details.code: RECEIPT_PENDING on submit | evm | The receipt is not confirmed yet | Retry the same txHash + Idempotency-Key after Retry-After |
details.code: PREPARATION_EXPIRED (410) on submit | evm | The calldata is past its 24-hour window | prepare → send the new calldata → submit |
details.code: VERIFICATION_FAILED (422) on submit | evm | Reverted, wrong contract, wrong recipient, or a limit that does not match the clamped value | Check chainRef, to, and the sender, then prepare again |
details.code: LIMIT_REQUIRED on prepare | evm | The wallet has no recorded limit to re-prepare from | Re-register the address with a limitMinor |
details.code: OPERATION_IN_FLIGHT (409) on prepare | both | A prepared or submitted grant is still awaiting its receipt | Submit it, or let it expire, before preparing another |
details.code: OPERATION_UNBOUND (409) on submit | evm | The preparation is not bound to a chain height | prepare again, submit against the new calldata |
details.code: TX_HASH_MISMATCH (409) on submit | evm | A different txHash than the one already recorded | Resend the original hash |
details.code: RECIPIENT_STATE_MISMATCH (422) on submit | evm | The receipt verified, but another transaction changed the authorization since | Read the wallet; prepare again if you still want the grant |
Full endpoint reference: Wallets API.