nora

Wallet whitelisting

Register a minter wallet via /v2/wallets, sign the grant with your whitelister wallet, and activate it. Two paths - prepared or self-built.

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. Your whitelister wallet signs the grant and pays the rent and the fee. Nora does not sign routine grants.

Prerequisites

  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

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

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 the grant. It returns 422 with details.code of SYNC_PROOF_REQUIRED. The wallet stays pending with statusReason: "awaiting_sync_proof".

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.

Failure modes

SymptomCauseRecovery
details.code: TRANSACTION_EXPIRED on submitThe blockhash expired before signingprepare → re-sign → submit
details.code: TRANSACTION_MISMATCH on submitThe bytes differ from the preparationRe-sign the staged transaction without changes
details.code: SYNC_PROOF_REQUIREDGrant observed, authorship unprovensync with the grant's txSignature
details.code: WHITELISTER_NOT_ACTIVEThe whitelister is revoked or pendingFix the whitelister on the dashboard first

Full endpoint reference: Wallets API.

On this page