Skip to main content

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: importReceivedNote always stamps the note PENDING, regardless of any status you pass in. It is not spendable until a discoverNotes() sync confirms it on-chain and promotes it to ACTIVE. 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 ownerAddress must be the address your current session was built for. A mismatch rejects the import immediately with InvalidNoteError; 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 to ACTIVE.

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.