Wallets
Register and manage whitelisted minter wallets on Solana, Ethereum, Base and Polygon. You sign every grant. Nora does not sign routine grants.
A wallet needs an on-chain whitelist grant before it can receive mints or
burn BRS in your flows. This surface registers minter wallets for your
organization and activates their grant. The server fixes the role to
minter; the request schema has no role field. You register whitelister
wallets on the dashboard, not through this API.
chainId accepts "solana" (the default), "ethereum", "base" or "polygon"; every chain is live in both environments (see BRS token). The solana and
EVM (ethereum, base, polygon) arms share every path and every response shape, and differ in
three fields: who signs, what you send to submit, and whether a
limitMinor applies. Each section below marks the difference.
Signing model
You sign every grant. Nora does not sign routine grants and does not hold the signing key.
solana. Your whitelister wallet signs the grant transaction. It also pays the rent and the fee. Two paths complete a registration:
- Prepared: Nora returns an unsigned transaction. You sign it with your
whitelister and return it via
submit. - Self-built: you build and broadcast your own
whitelisttransaction. Then you callsyncwith thetxSignatureas authorship proof.
ethereum. The signing contract is your organization's own BRSWhitelister,
provisioned by Nora. There is no whitelisterWallet request field — Nora
resolves the contract server-side. Nora returns wallet.calldata and
transaction is null; the contract's owner wallet (reported as
calldata.signerWallet) signs and broadcasts that call, and you return the
resulting txHash via submit.
See the wallet whitelisting flow for the full sequence.
Daily limits (ethereum)
limitMinor is a daily limit in BRL cents — the same unit as
amountCents on intents. 100000 is R$1.000,00. It is not token units;
Nora scales it to the token's own decimals at the chain boundary, and every
response reports cents back.
The whitelister contract clamps the requested value to your organization's
on-chain ceiling. A clamp is a success, not an error. The wallet becomes
active with the effective (lower) limitMinor and a statusReason such as:
limit_clamped: requested 200000000, effective 100000000 (the whitelister's on-chain ceiling)Once a wallet is active, its limitMinor cannot be changed through any
/v2 endpoint. prepare returns 200 with calldata: null — a silent
no-op, not an error. Raising or lowering an active wallet's limit requires
Nora support.
Wallet states
| State | Meaning |
|---|---|
pending | Registered. The grant is not yet proven on-chain. |
active | The grant was confirmed and proven. It passes intent preflights only when reconciliationStatus is synced. |
revoked / banned | The grant is gone or the address is barred. On ethereum a revoked wallet can be repaired through this API — see below. |
statusReason: "awaiting_sync_proof" marks a pending wallet with observed
on-chain state and no authorship proof. Call sync with the grant's
txSignature to activate it. (solana only — on ethereum the contract state
is the proof, so sync needs nothing in the body.)
Revocation is not instant on ethereum. If the authorization is zeroed
out-of-band, the first sync reports outcome: "not_on_chain" while state
stays active. The row flips to revoked only once the zero is confirmed at
the chain's confirmation depth (a few blocks; the exact depth per environment is an operational setting).
A revoked ethereum wallet can be repaired. POST /v2/wallets/:id/prepare
issues fresh positive calldata for the last limit the wallet requested,
and submit returns it to active. No re-registration is needed.
Response schemas
Wallet
| Field | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes | Nora wallet-registration ID. |
address | string | Yes | Base58 on solana, 0x-prefixed on ethereum. |
chainId | "solana" | "ethereum" | "base" | "polygon" | Yes | Chain for this registration. |
role | "minter" | "whitelister" | "buyer" | "buyerAndMinter" | "admin" | Yes | The API creates only minter rows. Other roles are read-only. |
label | string | null | Yes | Partner-defined label. |
state | "pending" | "active" | "revoked" | "banned" | Yes | Current registration state. |
statusReason | string | null | Yes | Diagnostic for a pending registration or an on-chain drift. It can include "awaiting_sync_proof", "awaiting_onchain_confirmation", "address_unavailable", "not_on_chain", or (ethereum) "limit_clamped: …". |
reconciliationStatus | "synced" | "missing_onchain" | "drifted" | Yes | Agreement between the row and on-chain state. |
whitelisterWallet | string | null | Yes | The wallet that signs the grant. Your whitelister on solana; the whitelister contract's owner on ethereum. |
limitMinor | integer | null | Yes | Effective, already-clamped daily limit in BRL cents. Always null on solana. |
calldata | Calldata | null | Yes | The EVM call still awaiting your broadcast. Always null on solana, and null on ethereum when there is nothing left to sign. |
createdAt | string | Yes | ISO 8601 creation time. |
updatedAt | string | Yes | ISO 8601 last-update time. |
Operation
| Field | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | Yes | Operation ID. |
operationType | "grant" | "regrant" | "accept_onchain" | "revoke" | "ban" | "repair" | Yes | Action represented by this operation. |
status | "prepared" | "linked_existing" | "submitted" | "confirmed" | "failed" | "expired" | Yes | Current operation state. |
txSignature | string | null | Yes | Broadcast transaction signature, when available. |
errorMessage | string | null | Yes | Failure diagnostic, when available. |
createdAt | string | Yes | ISO 8601 creation time. |
updatedAt | string | Yes | ISO 8601 last-update time. |
Transaction (solana)
transaction is the solana preparation. It is always null on ethereum —
the EVM preparation lives at wallet.calldata instead. transaction is also
null when the wallet has no current preparation.
| Field | Type | Required | Description |
|---|---|---|---|
unsignedTransaction | string | Yes | Base64-encoded Solana transaction. |
blockhash | string | Yes | Recent blockhash used by the preparation. |
lastValidBlockHeight | integer | Yes | Last block height at which the transaction is valid. |
expiresAt | string | Yes | ISO 8601 expiration time. |
signerWallet | string | Yes | Bound whitelister and transaction fee payer. |
instruction | Instruction | null | Yes | Decomposed grant instruction for transaction composition. null when the stored preparation is not decodable. |
Instruction has these fields:
| Field | Type | Required | Description |
|---|---|---|---|
programId | string | Yes | Solana program address. |
keys | InstructionKey[] | Yes | Ordered instruction accounts. |
data | string | Yes | Base64-encoded instruction data. |
Each InstructionKey has pubkey (string), isSigner (boolean), and
isWritable (boolean). Preserve the array order when you build a transaction.
Calldata (ethereum)
wallet.calldata is the EVM sibling of transaction: the exact call you send
from signerWallet. It is null on solana, and null on ethereum whenever
there is nothing left to sign (including on an active wallet).
| Field | Type | Required | Description |
|---|---|---|---|
to | string | Yes | Your organization's BRSWhitelister contract. |
data | string | Yes | ABI-encoded call data, 0x-prefixed. |
chainRef | string | Yes | CAIP-2 ref of the chain the call is valid on — "ethereum" alone cannot say Sepolia vs mainnet. |
signerWallet | string | Yes | The only wallet that may send it: the whitelister contract's owner. Nora does not hold this key. |
limitMinor | integer | Yes | The requested daily limit in BRL cents, before the on-chain clamp. |
expiresAt | string | Yes | ISO 8601. The window is 24 hours. Past it, submit returns 410 and you call prepare again. |
Idempotency flags
Create and submit responses include two booleans:
linkedExistingistruewhen Nora finds a proven on-chain grant and activates the wallet without a new broadcast.replayedistruewhen Nora returns the stored response for the sameIdempotency-Keyand request body. It isfalsefor the first execution.
GET /v2/wallets
List your registered wallets. Whitelister rows appear read-only.
The list returns only minter and whitelister rows. A detail request can
also return a legacy read-only role.
| Query | Type | Notes |
|---|---|---|
state | enum | pending | active | revoked | banned |
role | enum | minter | whitelister |
Response 200
{ wallets: Wallet[] }
GET /v2/wallets/:id
Wallet detail.
Response 200
{ wallet: Wallet, transaction: Transaction | null, operations: Operation[] }
transaction holds the current preparation (unsignedTransaction,
blockhash, lastValidBlockHeight, expiresAt, signerWallet,
instruction) or null. instruction is the decomposed grant instruction
(programId, keys, data in base64) for partners who compose their own
transaction. operations contains at most 25 records, newest first. Poll
this endpoint to observe state transitions; do not use it as an audit-history
feed.
POST /v2/wallets
Register a minter wallet. The response stages the grant you must sign.
Headers
| Header | Required | Notes |
|---|---|---|
X-API-Key | Yes | |
Idempotency-Key | Yes | UUID. The same key with a different payload returns 409. |
Content-Type | Yes | application/json |
Request body — solana
| Field | Type | Required | Description |
|---|---|---|---|
address | string | Yes | Base58 wallet to whitelist as minter. |
whitelisterWallet | string | Yes | Your active whitelister. It signs the grant. |
chainId | "solana" | No | Default "solana". |
label | string | No | Up to 64 characters. |
Request body — ethereum
| Field | Type | Required | Description |
|---|---|---|---|
chainId | "ethereum" | "base" | "polygon" | Yes | Required here — it is the discriminator that picks this arm, so it has no default. |
address | string | Yes | 0x-prefixed wallet to whitelist as minter. |
limitMinor | integer | Yes | Requested daily limit in BRL cents. 100000 is R$1.000,00. Positive integer. |
label | string | No | Up to 64 characters. |
This body is strict. There is no whitelisterWallet on the ethereum arm —
Nora resolves your organization's whitelister contract server-side, and
sending the field returns 400. If no contract is provisioned for your
organization yet, the call returns 409 with details.code of
WHITELISTER_NOT_PROVISIONED.
Response 200 / 201
{ wallet: Wallet, operation: Operation | null, transaction: Transaction | null, linkedExisting: boolean, replayed: boolean }
solana. When transaction is present, transaction.unsignedTransaction
is base64. Its feePayer is your whitelister. transaction.instruction
carries the same grant decomposed (programId, keys, data) for your own
composition.
ethereum / base / polygon. transaction is always null. When a grant
is needed it is at wallet.calldata — send it from calldata.signerWallet on
the network calldata.chainRef names, then call submit with the txHash
you get back.
solana. If a proven grant already exists on-chain at create time, the
wallet activates without a broadcast, there is nothing to sign, and
linkedExisting is true.
The EVM create path reads the chain. If your whitelister does not yet
authorize the address, the wallet comes back pending with wallet.calldata.
If it already authorizes it, the wallet comes back active at the on-chain
limit with calldata: null — there is nothing to sign and nothing to submit.
Branch on wallet.calldata: null means the wallet is already whitelisted
and you are done; an object means send it and then call submit.
POST /v2/wallets/:id/prepare
Return the current preparation, or a fresh one after the old one expired. The bound signer never changes.
solana. Returns the same preparation, including its decomposed
instruction, while blockHeight is at or below lastValidBlockHeight. It
generates a new one only after expiry.
ethereum. Returns the same wallet.calldata while its 24-hour window is
open, and re-prepares it — same requested limit, same signer — once the window
closed or a submit failed. Two cases return calldata: null:
- The wallet is
active. There is nothing left to sign, and this route cannot change an active wallet's limit.200, not an error. - A grant is already live on-chain. Nora adopts it and
activatedistrue.
A revoked wallet re-prepares fresh positive calldata for the last
limit it requested — submit it and the wallet returns to active.
If the wallet has no recorded limit to re-prepare from, the call returns 409
with details.code of LIMIT_REQUIRED: re-register the address with a
limitMinor.
Response 200
{ wallet: Wallet, transaction: Transaction | null, activated: boolean }
On solana, transaction is null when Nora observes an on-chain grant; if
activated is false, call sync with the grant transaction signature to
provide authorship proof.
POST /v2/wallets/:id/submit
Complete the grant. Requires Idempotency-Key, with the same rules as create.
Send the field your wallet's chain uses — the other one returns 400 naming
the expected field.
Request body — solana
| Field | Type | Required | Description |
|---|---|---|---|
signedTransaction | string | Yes | The prepared transaction, signed, base64. |
Nora verifies the signed bytes against the preparation and broadcasts them.
The fee payer, the blockhash, and the instructions must match. You may add
only bounded ComputeBudget instructions. A mismatch returns details.code of
TRANSACTION_MISMATCH. An expired preparation returns details.code of
TRANSACTION_EXPIRED while the preparation is still stored; once it has been
dropped the same case is a plain 400 BAD_REQUEST with
message: "Wallet has no pending prepared transaction to submit — call prepare (or sync, to adopt an existing grant with proof) first". Either way: call
prepare, re-sign, and submit again with a new Idempotency-Key. A Solana
preparation lives only as long as its blockhash, well under the time a
human signer or a multisig round-trip takes; call prepare right before
signing.
Request body — ethereum
| Field | Type | Required | Description |
|---|---|---|---|
txHash | string | Yes | The hash of the transaction you broadcast — 0x plus 64 hex characters. Not signedTransaction, not txSignature. |
You broadcast the calldata yourself; Nora verifies the receipt. The emitted grant must match the prepared recipient and the clamped limit.
| Outcome | Response |
|---|---|
| Confirmed | 200. The wallet is active (or revoked for a zero grant) with the effective limitMinor. |
| Not mined or not yet confirmed | 409 RECEIPT_PENDING with a Retry-After header. Retry with the same txHash and Idempotency-Key. |
| Preparation past its window | 410 PREPARATION_EXPIRED. Call prepare for fresh calldata. |
| Receipt does not satisfy the grant | 422 VERIFICATION_FAILED. Reverted, wrong contract, wrong recipient, or a limit that does not match the clamped value. prepare again. |
| Receipt fine, but another transaction changed the authorization since | 422 RECIPIENT_STATE_MISMATCH. Read the wallet; prepare again if you still want the grant. |
A different txHash for the same operation | 409 TX_HASH_MISMATCH. Resend the original hash. |
| Preparation not bound to a chain height | 409 OPERATION_UNBOUND. Call prepare again and submit against the new calldata. |
Response 200
{ wallet: Wallet, operation: Operation, linkedExisting: boolean, replayed: boolean }
POST /v2/wallets/:id/sync
Reconcile the wallet from on-chain state. Use it after you broadcast your own transaction, or anytime as a drift re-check.
| Field | Type | Required | Description |
|---|---|---|---|
txSignature | string | No | solana only. The grant transaction's signature. Required to activate a grant Nora did not broadcast. |
solana. Activation requires authorship proof. The grant must bind to this
registration's own preparation, or the txSignature's
WhitelistUpdatedEvent.updated_by must be one of your active whitelisters.
When Nora observes a grant on-chain that it cannot attribute, the call
returns 422 with details.code of SYNC_PROOF_REQUIRED and the wallet
stays pending with statusReason: "awaiting_sync_proof". When there is no
grant on-chain at all, the call is a 200 with outcome: "not_on_chain"
and the row is unchanged.
ethereum. No proof is needed and the body can be empty — Nora reads the
recipient's authorization straight from your whitelister contract, and the
contract state is the proof. A live limit activates the wallet with that
effective value. A zeroed authorization does not flip the row on the first
read: the outcome is not_on_chain and the state stays active until the
zero is confirmed at the chain's confirmation depth.
Response 200
{ outcome: "activated" | "already_synced" | "not_on_chain", wallet: Wallet, operation: Operation | null }
operation can be null or a record for any outcome (a not_on_chain
read on Ethereum can carry a confirmed repair record, for example).
Treat outcome as the result and operation as an audit detail, never as
a state change.
DELETE /v2/wallets/:id
Cancel a pending registration, on either chain. The row and its operations
are hard-deleted and the address is freed: a follow-up GET returns
404, and you can register the same address again as a fresh registration.
This API cannot delete active wallets — that is a 409 with details.code of
WALLET_NOT_PENDING, or ALREADY_ACTIVE when the pre-delete on-chain check
already sees the grant.
Response 200
{ id: string }
Error envelope
Every wallet endpoint returns this shape for non-2xx responses:
{
"code": "CONFLICT",
"message": "The prepared transaction has expired.",
"details": { "code": "TRANSACTION_EXPIRED" }
}code and message are always present, except on structural validation
failures (missing Idempotency-Key, mistyped body), which come back as
{ "data": …, "error": [{ "code", "path", "message" }], "success": false }.
details is optional. For a wallet-specific error, use details.code for
recovery. Use the top-level code only for the general error category.
Machine error codes
| Code | Meaning |
|---|---|
WHITELISTER_NOT_FOUND | whitelisterWallet is not registered for your org and environment. |
WHITELISTER_NOT_ACTIVE | The whitelister exists and is not active. |
WHITELISTER_NOT_PROVISIONED | (ethereum) No whitelister contract is provisioned for your org yet. Contact Nora. |
WALLET_ALREADY_REGISTERED | The address already has a row in your org. |
ADDRESS_UNAVAILABLE | The address cannot be registered. |
WALLET_BANNED | The address is banned on-chain. |
TRANSACTION_EXPIRED | The preparation's blockhash expired. Call prepare again. |
TRANSACTION_MISMATCH | The signed bytes do not match the preparation. |
ALREADY_ACTIVE | The wallet is already active. |
NOT_ON_CHAIN | sync found no grant, or submit could not confirm an exact minter grant. Poll the wallet or call sync. |
SYNC_PROOF_REQUIRED | On-chain state exists and authorship proof is missing. |
IDEMPOTENCY_KEY_MISMATCH | The same Idempotency-Key arrived with a different payload. |
WALLET_NOT_PENDING | Only pending registrations can be cancelled. |
WALLET_NOT_MANAGED | The wallet is not a minter registration managed by this API. |
WALLET_CAP_EXCEEDED | The organization reached its active and pending minter-wallet limit. |
RECEIPT_PENDING | (ethereum) The receipt is not confirmed yet. Retry submit with the same txHash and Idempotency-Key after Retry-After. |
PREPARATION_EXPIRED | (ethereum) The prepared calldata is past its 24-hour window. Call prepare. |
VERIFICATION_FAILED | (ethereum) The receipt does not satisfy the prepared grant. |
LIMIT_REQUIRED | (ethereum) The wallet has no recorded limit to re-prepare from. Re-register the address with a limitMinor. |
RECIPIENT_STATE_MISMATCH | (ethereum, submit) The receipt verified, but another transaction changed the recipient's authorization since. Read the wallet and prepare again if you still want the grant. |
TX_HASH_MISMATCH | (ethereum, submit) A different txHash was sent for an operation that already has one. Resend the original. |
OPERATION_UNBOUND | (ethereum) The preparation could not be bound to a chain height. Call prepare again and submit against the new calldata. |
OPERATION_IN_FLIGHT | (prepare) A prepared or submitted grant is still awaiting its receipt. Submit it, or let it expire, before preparing another. |
Rate limits
prepare, submit, and sync share a dedicated limit of 20 requests per
minute per API key. This limit is in addition to the global per-key limit.
Poll GET /v2/wallets/:id for status. Do not poll sync.