Import a note received out-of-band
What this lets you do
Manually import a note into your local NoteManager when the sender delivered it out-of-band. This is common when the recipient hasn't registered a viewing key: there is no encrypted payload on-chain to discover, so the sender hands you the noteSecret directly via a secure channel, often using a payment receipt.
Constraints & limits
- You need the full note material: commitment, value, tokenId, label, ownerAddress, noteSecret, noteAddressHash. If any field is missing, the SDK rejects the import.
- The commitment must actually be on-chain at the configured pool. Importing a fake or never-published note makes it appear locally, but the first spend will fail.
- Status is forced to
PENDING:importReceivedNotealways stamps the notePENDING, regardless of any status you pass in. It is not spendable until adiscoverNotes()sync confirms it on-chain and promotes it toACTIVE. That happens when discovery finds the note's commitment among the outputs of the transfer that created it, or when the ASP reports its label approved. - Owner-binding: the note's
ownerAddressmust be the address your current session was built for. A mismatch rejects the import immediately withInvalidNoteError; the note is never stored.
What it unlocks next
- Spend the note via a private transfer.
- Withdraw the note's value to a public address.
- Show the imported note in your UI immediately as
PENDING. It becomes spendable once the next discovery sync promotes it toACTIVE.
How to use it
import { NoteStatus } from "@privacy-pools-v2/sdk";
import { parseAbi, toHex } from "viem";
// You have a receipt JSON from the sender:
const receipt = JSON.parse(receiptText);
// createdAtBlock must hold the commitment's on-chain timestamp:
// PoolVault.commitments(commitment) stores block.timestamp, and spend
// proofs recompute the state-tree leaf from it. (discoverNotes() resolves
// this automatically; for a manual import, read it from the pool.)
const createdAtTimestamp = await client.readContract({
address: receipt.pool.poolAddress,
abi: parseAbi(["function commitments(uint256) view returns (uint256)"]),
functionName: "commitments",
args: [BigInt(receipt.note.commitment)],
});
await session.importReceivedNote({
commitment: receipt.note.commitment,
value: receipt.note.value,
tokenId: receipt.note.tokenId,
label: receipt.note.label,
ownerAddress: receipt.note.ownerAddress,
noteSecret: receipt.note.noteSecret,
noteAddressHash: receipt.note.noteAddressHash,
status: NoteStatus.PENDING,
createdAtBlock: toHex(createdAtTimestamp),
spentAtBlock: null,
txHash: receipt.payment.txHash,
isOwned: true,
});
Why this matters: import-from-receipt is the bridge that makes the no-registration UX work. Recipients don't need to set anything up on-chain before they can claim a payment: the sender sends them a JSON receipt over a secure channel, they import it, and their balance updates.
Behind the scenes
SDK call
session.importReceivedNote(note)No on-chain effect A local-only mutation of NoteManager state, followed by a persistent-storage flush.
Verification At import time, the SDK recomputes the noteAddressHash and commitment from the imported fields and checks they bind together (a field-internal consistency check, not an on-chain comparison). Any mismatch throws
InvalidNoteError immediately, before the note is stored.