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
- 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
- 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
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 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
| 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.
Failure modes
| Symptom | Cause | Recovery |
|---|---|---|
details.code: TRANSACTION_EXPIRED on submit | The blockhash expired before signing | prepare → re-sign → submit |
details.code: TRANSACTION_MISMATCH on submit | The bytes differ from the preparation | Re-sign the staged transaction without changes |
details.code: SYNC_PROOF_REQUIRED | Grant observed, authorship unproven | sync with the grant's txSignature |
details.code: WHITELISTER_NOT_ACTIVE | The whitelister is revoked or pending | Fix the whitelister on the dashboard first |
Full endpoint reference: Wallets API.