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:

EnvironmentClusterMint
SandboxdevnetBRS2CELW6Cueo2mrMUVvAr5GDT7Pw8TeostC2JLMpBk4
ProductionmainnetBRSxQRUaGswjLs7ewcH7uXj3r7SgmfKSSLLXyCKHZtUo

Explorer links and the EVM addresses are on the Addresses page.

  1. An active whitelister wallet registered for your organization on the dashboard. The API does not register whitelisters.
  2. The whitelister keypair available to your server, with SOL for rent and fees (~0.002 SOL per wallet).
  3. An API key. See Authentication.

Path A — prepared transaction (solana)

Loading diagram…
  1. Register. POST /v2/wallets with { address, whitelisterWallet } and an Idempotency-Key. The response stages a base64 unsigned grant transaction. Its feePayer is your whitelister.
  2. 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.
  3. Submit. POST /v2/wallets/:id/submit with { signedTransaction } and a new Idempotency-Key. Nora broadcasts, confirms, and activates.
  4. Confirm. GET /v2/wallets/:id returns state: "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

  1. Register. Call POST /v2/wallets. For a new registration, the response holds the pending wallet and transaction.instruction — the grant as programId, keys, and base64 data.
  2. Rebuild the instruction from that field.
  3. Compose the transaction. Add the instruction. Set the fee payer to your whitelister wallet. Set a recent blockhash or a durable nonce.
  4. Sign with your whitelister keypair and broadcast the transaction.
  5. Sync with proof. Call POST /v2/wallets/:id/sync with the txSignature. Nora reads the on-chain WhitelistUpdatedEvent. 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

EnvironmentClusterProgram ID
Sandboxdevnet9fBSeVHUCaHHUzkktiRp5Yn35emxx3S1ERzn7oHsi8je
ProductionmainnetnoRAkuzHAdMjsbj2P9gNPMm94hui54NLkBDXe6uasVt

Accounts (in this order)

#AccountSignerWritableDerivation
1whitelisterYesYesYour whitelister wallet. Also the fee payer.
2whitelister_pdaNoNoPDA: seeds ["whitelist", whitelister]
3userNoNoThe wallet to whitelist.
4whitelist_pdaNoYesPDA: seeds ["whitelist", user]
5system_programNoNo11111111111111111111111111111111

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)

  1. A BRSWhitelister contract provisioned by Nora for your organization. Until it exists, POST /v2/wallets returns 409 with details.code of WHITELISTER_NOT_PROVISIONED.
  2. The contract's owner wallet available to your server, funded for gas. Nora never holds that key.
  3. An API key.
Loading diagram…

Steps

  1. Register. POST /v2/wallets with { chainId: "ethereum", address, limitMinor } and an Idempotency-Key. limitMinor is the requested daily limit in BRL cents — 100000 is R$1.000,00, not token units. Do not send whitelisterWallet: the body is strict on this arm and the field is a 400.

  2. Send the calldata. The response carries wallet.calldata (to, data, chainRef, signerWallet, limitMinor, expiresAt) and transaction: null. Send { to, data, value: 0 } from signerWallet on the chain named by chainRef — not on whatever network your wallet happens to be connected to. The window is 24 hours.

    calldata is null when the whitelister already authorizes the address: the wallet comes back active at its on-chain limit, and steps 2–5 do not run. Branch on wallet.calldata, not on state.

  3. Submit the hash. POST /v2/wallets/:id/submit with { txHash } and a new Idempotency-Key. The field is txHash — not signedTransaction and not txSignature. Nora reads the receipt and verifies the emitted grant against the prepared recipient and limit.

  4. Handle RECEIPT_PENDING. While the receipt is unconfirmed, submit returns 409 with details.code: RECEIPT_PENDING and a Retry-After header. Retry with the same txHash and the same Idempotency-Key. Respect Retry-After — this route has a tighter per-key budget than the global limit.

  5. Confirm. GET /v2/wallets/:id returns state: "active" with the effective limitMinor.

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/:id hard-deletes the row and frees the address: GET afterwards is 404, and you can register the same address again from scratch. On an active wallet this is a 409.
  • A revoke is not instant. If the authorization is zeroed out-of-band, the first POST /v2/wallets/:id/sync reports outcome: "not_on_chain" while the state stays active. The row flips to revoked only once the zero is confirmed at the chain's confirmation depth.
  • Repair a revoked wallet. POST /v2/wallets/:id/prepare issues fresh positive calldata for the last limit the wallet requested. Send it, call submit with the new txHash, and the wallet returns to active. 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

SymptomChainCauseRecovery
details.code: TRANSACTION_EXPIRED on submitsolanaThe blockhash expired before signingprepare → re-sign → submit
details.code: TRANSACTION_MISMATCH on submitsolanaThe bytes differ from the preparationRe-sign the staged transaction without changes
details.code: SYNC_PROOF_REQUIREDsolanaGrant observed, authorship unprovensync with the grant's txSignature
details.code: WHITELISTER_NOT_ACTIVEsolanaThe whitelister is revoked or pendingFix the whitelister on the dashboard first
details.code: WHITELISTER_NOT_PROVISIONEDevmNo whitelister contract exists for your orgContact Nora
400 naming whitelisterWalletevmThe field was sent on the EVM armDrop it — Nora resolves the contract server-side
details.code: RECEIPT_PENDING on submitevmThe receipt is not confirmed yetRetry the same txHash + Idempotency-Key after Retry-After
details.code: PREPARATION_EXPIRED (410) on submitevmThe calldata is past its 24-hour windowprepare → send the new calldata → submit
details.code: VERIFICATION_FAILED (422) on submitevmReverted, wrong contract, wrong recipient, or a limit that does not match the clamped valueCheck chainRef, to, and the sender, then prepare again
details.code: LIMIT_REQUIRED on prepareevmThe wallet has no recorded limit to re-prepare fromRe-register the address with a limitMinor
details.code: OPERATION_IN_FLIGHT (409) on preparebothA prepared or submitted grant is still awaiting its receiptSubmit it, or let it expire, before preparing another
details.code: OPERATION_UNBOUND (409) on submitevmThe preparation is not bound to a chain heightprepare again, submit against the new calldata
details.code: TX_HASH_MISMATCH (409) on submitevmA different txHash than the one already recordedResend the original hash
details.code: RECIPIENT_STATE_MISMATCH (422) on submitevmThe receipt verified, but another transaction changed the authorization sinceRead the wallet; prepare again if you still want the grant

Full endpoint reference: Wallets API.

On this page