{"version":3,"sources":["../src/discovery.ts","../src/encryption.ts","../src/keys.ts"],"sourcesContent":["// Private Pool V2 — note discovery + ownership tracking.\n//\n// Recipient flow (\"walk into a coffee shop, open my wallet\"):\n//   1. Pull every encrypted note blob from `/api/private-pool/v2/notes`.\n//   2. Trial-decrypt each one with the user's `viewing_sk`.\n//   3. The blobs that decrypt cleanly are the user's notes — collect them.\n//   4. For each owned note, query `/api/private-pool/v2/nullifier/:n`\n//      to determine spent status.\n//   5. Sum unspent values for the displayed balance.\n//\n// Privacy properties this preserves:\n//   - The server doesn't know which notes are yours (it serves every blob).\n//   - The nullifier check tells the server \"is this nullifier spent?\"\n//     without revealing whose. Each query is unlinkable IF the queries\n//     are spread over time (request batching → linkability).\n//   - We trial-decrypt every blob client-side; the only metadata leak is\n//     network timing on the bulk fetch, which is benign because blobs\n//     are public anyway.\n\nimport { tryDecryptNote, type DecryptedNote, type EncryptedNoteBlob } from './encryption';\nimport { poseidon1, fieldToBytes32BE } from './keys';\nimport { buildPoseidon } from 'circomlibjs';\n\nconst FIELD_MODULUS =\n  21888242871839275222246405745257275088548364400416034343698204186575808495617n;\n\n// ── API shapes (mirror server/src/routes/private-pool-v2.js) ────────────\n\nexport type EncryptedNoteApiBlob = {\n  pda: string;\n  commitment_hex: string;\n  ephemeral_pk_hex: string;\n  ciphertext_b64: string;\n  ciphertext_len: number;\n};\n\nexport type CommitmentLeafApiEntry = {\n  pda: string;\n  commitment_hex: string;\n  commitment_decimal: string;\n  leaf_index: number;\n  block_slot: number;\n};\n\nexport type NullifierStatusApi = {\n  network: string;\n  nullifier: string;\n  consumed: boolean;\n  used_at_slot?: number;\n  pda?: string;\n};\n\n// ── Owned-note shape ─────────────────────────────────────────────────────\n\nexport type OwnedNote = DecryptedNote & {\n  /// 32-byte commitment as bytes (BE field element). Mirrors the on-chain\n  /// `commitment` field on `CommitmentLeafAccount`.\n  commitment: Uint8Array;\n  /// `0x…64-char-hex` representation of `commitment`.\n  commitmentHex: string;\n  /// `commitment` as a bigint reduced mod the BN254 scalar field. The\n  /// circuit witness uses this form directly.\n  commitmentField: bigint;\n  /// Position in the merkle tree (or `null` when the indexer hasn't\n  /// caught the leaf record yet — happens for ~1s after a fresh deposit).\n  leafIndex: number | null;\n  /// Block slot the leaf was inserted in (best-effort timestamp).\n  blockSlot: number | null;\n  /// Has the matching nullifier been published on chain?\n  spent: boolean;\n  /// When `spent === true`, the slot the nullifier landed in.\n  spentAtSlot?: number;\n};\n\n// ── HTTP helpers ─────────────────────────────────────────────────────────\n\nfunction buildUrl(apiBaseUrl: string, path: string, query: Record<string, string | number | undefined> = {}): string {\n  const url = new URL(path, apiBaseUrl.endsWith('/') ? apiBaseUrl : apiBaseUrl + '/');\n  for (const [k, v] of Object.entries(query)) {\n    if (v !== undefined) url.searchParams.set(k, String(v));\n  }\n  return url.toString();\n}\n\nasync function getJson<T>(url: string, init?: RequestInit): Promise<T> {\n  const r = await fetch(url, init);\n  if (!r.ok) throw new Error(`GET ${url} → ${r.status} ${await r.text()}`);\n  return (await r.json()) as T;\n}\n\n/// Pull every encrypted blob the indexer has published. Filtering is\n/// done client-side (we trial-decrypt each one with our viewing key).\nexport async function fetchEncryptedNoteBlobs(args: {\n  apiBaseUrl: string;\n  network: string;\n  sinceSlot?: number;\n}): Promise<EncryptedNoteApiBlob[]> {\n  const url = buildUrl(args.apiBaseUrl, 'api/private-pool/v2/notes', {\n    network: args.network,\n    sinceSlot: args.sinceSlot,\n  });\n  const body = await getJson<{ blobs: EncryptedNoteApiBlob[] }>(url);\n  return body.blobs;\n}\n\n/// Pull every commitment-leaf record (used to attach `leafIndex` to\n/// each owned note). Cheap enough to call alongside the blob fetch.\nexport async function fetchCommitmentLeaves(args: {\n  apiBaseUrl: string;\n  network: string;\n  since?: number;\n}): Promise<CommitmentLeafApiEntry[]> {\n  const url = buildUrl(args.apiBaseUrl, 'api/private-pool/v2/leaves', {\n    network: args.network,\n    since: args.since,\n  });\n  const body = await getJson<{ leaves: CommitmentLeafApiEntry[] }>(url);\n  return body.leaves;\n}\n\n/// Per-note nullifier check. The server returns `consumed: true|false`.\n/// Frontend should NOT batch all of these into a single request — see\n/// the privacy notes at the top of this module.\n///\n/// Note for hot-path balance reads: prefer `fetchAllNullifiers` (one\n/// round-trip + local `Set.has()`) over calling this in a Promise.all.\n/// Both reach the same on-chain markers, but the batch endpoint\n/// dedupes the cost across every owned note + every concurrent\n/// surface loading at once.\nexport async function fetchNullifierStatus(args: {\n  apiBaseUrl: string;\n  network: string;\n  nullifier: bigint | Uint8Array | string;\n}): Promise<NullifierStatusApi> {\n  let asString: string;\n  if (typeof args.nullifier === 'bigint') {\n    asString = '0x' + args.nullifier.toString(16).padStart(64, '0');\n  } else if (args.nullifier instanceof Uint8Array) {\n    asString = '0x' + Buffer.from(args.nullifier).toString('hex');\n  } else {\n    asString = String(args.nullifier);\n  }\n  const url = buildUrl(\n    args.apiBaseUrl,\n    `api/private-pool/v2/nullifier/${encodeURIComponent(asString)}`,\n    { network: args.network },\n  );\n  return getJson<NullifierStatusApi>(url);\n}\n\n/// Pull every consumed nullifier hex in one request. Used by\n/// `attachSpentStatus` to do membership checks locally — strictly\n/// cheaper than N parallel hits to `/nullifier/:n` (single RPC call\n/// server-side, single cache slot, no per-PDA round-trip).\n///\n/// Trade-off vs. the per-nullifier variant: privacy-wise, on this\n/// endpoint the server still doesn't learn *which* of these\n/// nullifiers belong to the caller (frontend membership-tests them\n/// privately). It's strictly better than parallel /nullifier/:n\n/// fetches, where the server learned the exact set of nullifiers the\n/// caller wanted to check.\nexport async function fetchAllNullifiers(args: {\n  apiBaseUrl: string;\n  network: string;\n}): Promise<string[]> {\n  const url = buildUrl(args.apiBaseUrl, 'api/private-pool/v2/nullifiers/all', {\n    network: args.network,\n  });\n  const body = await getJson<{ nullifiers: string[] }>(url);\n  return body.nullifiers;\n}\n\n// ── Discovery primitives ─────────────────────────────────────────────────\n\nfunction hexToBytes(hex: string): Uint8Array {\n  const clean = hex.startsWith('0x') ? hex.slice(2) : hex;\n  return Uint8Array.from(Buffer.from(clean, 'hex'));\n}\n\n/// Trial-decrypt a list of encrypted blobs with `viewingSk`. Returns the\n/// blobs the auth tag accepts — i.e. the user's own notes. Pure (no RPC).\nexport function trialDecryptBlobs(args: {\n  blobs: EncryptedNoteApiBlob[];\n  viewingSk: Uint8Array;\n  /// Optional bound — stop after this many decryptions (test/dev hook;\n  /// production callers leave it undefined).\n  limit?: number;\n}): Array<{ blob: EncryptedNoteApiBlob; note: DecryptedNote }> {\n  const out: Array<{ blob: EncryptedNoteApiBlob; note: DecryptedNote }> = [];\n  for (const blob of args.blobs) {\n    const ephPk = hexToBytes(blob.ephemeral_pk_hex);\n    const commitment = hexToBytes(blob.commitment_hex);\n    const ciphertext = Uint8Array.from(Buffer.from(blob.ciphertext_b64, 'base64'));\n    const ownedBlob: EncryptedNoteBlob = { ephemeralPk: ephPk, ciphertext, commitment };\n    const note = tryDecryptNote({ blob: ownedBlob, viewingSk: args.viewingSk });\n    if (note) {\n      out.push({ blob, note });\n      if (args.limit && out.length >= args.limit) break;\n    }\n  }\n  return out;\n}\n\n/// One-shot fetch + trial-decrypt + leaf attachment. Returns owned notes\n/// without spent flags — call `attachSpentStatus` after to check chain.\n///\n/// `apiBaseUrl` should NOT include the trailing `/api/private-pool/v2`\n/// segment — pass the host root (e.g. `https://api.relai.fi`) and this\n/// helper appends the path.\nexport async function discoverOwnedNotes(args: {\n  apiBaseUrl: string;\n  network: string;\n  viewingSk: Uint8Array;\n}): Promise<OwnedNote[]> {\n  const [blobs, leaves] = await Promise.all([\n    fetchEncryptedNoteBlobs(args),\n    fetchCommitmentLeaves(args),\n  ]);\n  const leafByCommitment = new Map<string, CommitmentLeafApiEntry>();\n  for (const l of leaves) leafByCommitment.set(l.commitment_hex.toLowerCase(), l);\n\n  const decrypted = trialDecryptBlobs({ blobs, viewingSk: args.viewingSk });\n  return decrypted.map(({ blob, note }) => {\n    const commitment = hexToBytes(blob.commitment_hex);\n    const leaf = leafByCommitment.get(blob.commitment_hex.toLowerCase()) ?? null;\n    return {\n      ...note,\n      commitment,\n      commitmentHex: blob.commitment_hex,\n      commitmentField: BigInt(leaf?.commitment_decimal ?? '0x' + blob.commitment_hex.slice(2)),\n      leafIndex: leaf?.leaf_index ?? null,\n      blockSlot: leaf?.block_slot ?? null,\n      spent: false,\n    };\n  });\n}\n\n// ── Nullifier derivation + spent status ──────────────────────────────────\n\nlet cachedPoseidon: Awaited<ReturnType<typeof buildPoseidon>> | null = null;\nasync function getPoseidonInstance() {\n  if (!cachedPoseidon) cachedPoseidon = await buildPoseidon();\n  return cachedPoseidon!;\n}\n\n/// `nullifier = Poseidon2(commitment, nullifier_sk)`. Matches the\n/// circuit's `NullifierHash` template. Use this off-circuit when\n/// querying `fetchNullifierStatus` to check whether a note is spent.\nexport async function deriveNullifier(commitmentField: bigint, nullifierSkField: bigint): Promise<bigint> {\n  const p = (await getPoseidonInstance()) as any;\n  const h = p([commitmentField, nullifierSkField]);\n  return ((BigInt(p.F.toString(h)) % FIELD_MODULUS) + FIELD_MODULUS) % FIELD_MODULUS;\n}\n\n/// Decorate `notes` with their `spent` flag by checking each nullifier\n/// against the API.\n///\n/// Two evolutionary steps got us here:\n///   1. Original sequential per-note check — privacy-by-spreading. Was\n///      10-30 s of \"Loading…\" with a few dozen notes.\n///   2. Parallel `Promise.all` of per-note `/nullifier/:n` fetches —\n///      faster, but still N RPC calls server-side (and a 20-note\n///      wallet × every surface reloading at once = browser\n///      connection-limit pile-ups + duplicated `getProgramAccounts`\n///      pressure).\n///\n/// Current shape: ONE request to `/nullifiers/all` returning every\n/// consumed nullifier hex, then we Poseidon-derive each note's\n/// nullifier locally and check membership against a `Set`. N → 1\n/// round-trips; the membership check is O(N) on a Set lookup, dwarfed\n/// by the network call.\n///\n/// Privacy: the server-side batch fetch is content-agnostic (it\n/// hands back the full list regardless of caller), so it doesn't learn\n/// which subset belongs to this user. Strictly better than the parallel\n/// /nullifier/:n flow, where the server saw the exact set the caller\n/// wanted to test.\n///\n/// Loss-of-detail: the per-note `spentAtSlot` field used to be\n/// populated from `/nullifier/:n`. The batch endpoint omits it to keep\n/// the payload tight; if a caller needs `spentAtSlot` for a specific\n/// note (e.g. transaction-history reconciliation), they can call\n/// `fetchNullifierStatus` for that single nullifier on-demand. The hot\n/// balance-fetch path doesn't need it.\n///\n/// If you genuinely need the spread-over-time per-note variant (e.g.\n/// a paranoid privacy-mode toggle), call `attachSpentStatusSequential`\n/// below.\nexport async function attachSpentStatus(args: {\n  apiBaseUrl: string;\n  network: string;\n  notes: OwnedNote[];\n  nullifierSkField: bigint;\n}): Promise<OwnedNote[]> {\n  // Fetch all consumed nullifiers + derive ours in parallel (the\n  // Poseidon hashes are independent of the network round-trip, so\n  // there's no reason to wait for one before the other).\n  const [consumedHex, ownedNullifierHex] = await Promise.all([\n    fetchAllNullifiers({ apiBaseUrl: args.apiBaseUrl, network: args.network }).catch((): string[] => []),\n    Promise.all(\n      args.notes.map(async (n) => {\n        const nullifier = await deriveNullifier(n.commitmentField, args.nullifierSkField);\n        return '0x' + nullifier.toString(16).padStart(64, '0');\n      }),\n    ),\n  ])\n  // Normalise to lowercase so the membership check isn't bitten by\n  // 0xAbCd vs 0xabcd casing drift between client and server.\n  const consumedSet = new Set(consumedHex.map((h) => h.toLowerCase()))\n  return args.notes.map((note, i) => ({\n    ...note,\n    spent: consumedSet.has(ownedNullifierHex[i].toLowerCase()),\n  }))\n}\n\n/// Sequential variant — kept for callers who want the original\n/// privacy-conscious behaviour (one nullifier check per note, spread\n/// over time). Slow with N > 10 notes; do not use on UI hot paths.\nexport async function attachSpentStatusSequential(args: {\n  apiBaseUrl: string;\n  network: string;\n  notes: OwnedNote[];\n  nullifierSkField: bigint;\n}): Promise<OwnedNote[]> {\n  const out: OwnedNote[] = [];\n  for (const note of args.notes) {\n    const nullifier = await deriveNullifier(note.commitmentField, args.nullifierSkField);\n    try {\n      const status = await fetchNullifierStatus({\n        apiBaseUrl: args.apiBaseUrl,\n        network: args.network,\n        nullifier,\n      });\n      out.push({ ...note, spent: status.consumed, spentAtSlot: status.used_at_slot });\n    } catch {\n      out.push({ ...note });\n    }\n  }\n  return out;\n}\n\n/// Sum `value` across every unspent note. Returns micro-USDC bigint.\n/// Caller divides by 10^6 for human display.\nexport function totalUnspentBalance(notes: OwnedNote[]): bigint {\n  return notes.reduce((sum, n) => (n.spent ? sum : sum + n.value), 0n);\n}\n\n/// Same shape as `totalUnspentBalance` but breaks down per memo-tag\n/// (handy for receipts / reconciliation).\nexport function balanceBreakdown(notes: OwnedNote[]): {\n  totalMicroUsdc: bigint;\n  unspentCount: number;\n  spentCount: number;\n} {\n  let totalMicroUsdc = 0n;\n  let unspentCount = 0;\n  let spentCount = 0;\n  for (const n of notes) {\n    if (n.spent) spentCount++;\n    else {\n      totalMicroUsdc += n.value;\n      unspentCount++;\n    }\n  }\n  return { totalMicroUsdc, unspentCount, spentCount };\n}\n\n// re-exports for callers that don't want to pull `private-pool-keys`\n// directly (most discovery callers stay in this module).\nexport { fieldToBytes32BE, poseidon1 } from './keys';\n","// Private Pool V2 — encrypted note format (ADR-003).\n//\n//   ChaCha20-Poly1305 AEAD\n//   X25519 ephemeral key agreement → HKDF-SHA256(shared, salt, info=commitment)\n//   nonce = blake2b256(eph_pk || commitment)[:12]\n//   AAD   = commitment (32 bytes)\n//\n// Plaintext layout (memo-less, 80 bytes):\n//   0..8    value     (u64 LE, micro-USDC)\n//   8..40   blinding  (32 bytes BE)\n//   40..72  owner_pk  (32 bytes BE)\n//   72      memo flag (0x00)\n//   73..80  zero pad\n//\n// Plaintext layout (with memo, 112 bytes): same as above but\n//   72      memo flag (0x01)\n//   80..112 memo (32 bytes)\n//\n// On-chain `EncryptedNoteBlobAccount`:\n//   - ephemeral_pk: 32 bytes (separate field)\n//   - ciphertext:   96 or 128 bytes (Vec<u8>) — plaintext + 16-byte Poly1305 tag\n//\n// Conformance vectors: see `frontend/src/__tests__/private-pool-encryption.test.ts`.\n\nimport { chacha20poly1305 } from '@noble/ciphers/chacha';\nimport { x25519 } from '@noble/curves/ed25519';\nimport { hkdf } from '@noble/hashes/hkdf';\nimport { blake2b } from '@noble/hashes/blake2b';\nimport { sha256 } from '@noble/hashes/sha2';\n\nconst HKDF_SALT = new TextEncoder().encode('RelAI PrivatePool v2 note key');\nconst NONCE_LEN = 12;\nconst TAG_LEN = 16;\n\n// Plaintext layouts (legacy ADR-003 only — stealth was withdrawn,\n// see ADR-008 postmortem):\n//   80B  no memo:   value(8) + blinding(32) + owner_pk(32) + flag=0(1) + 7B pad\n//   112B with memo: above + 32B memo at offset 80, flag=1\nconst PLAINTEXT_NO_MEMO = 80;\nconst PLAINTEXT_WITH_MEMO = 112;\nconst CIPHERTEXT_NO_MEMO = PLAINTEXT_NO_MEMO + TAG_LEN; // 96\nconst CIPHERTEXT_WITH_MEMO = PLAINTEXT_WITH_MEMO + TAG_LEN; // 128\n\nconst EPHEMERAL_PK_LEN = 32;\nconst COMMITMENT_LEN = 32;\n\nexport const PRIVATE_POOL_NOTE_BYTES = {\n  PLAINTEXT_NO_MEMO,\n  PLAINTEXT_WITH_MEMO,\n  CIPHERTEXT_NO_MEMO,\n  CIPHERTEXT_WITH_MEMO,\n  EPHEMERAL_PK_LEN,\n  COMMITMENT_LEN,\n  NONCE_LEN,\n  TAG_LEN,\n} as const;\n\nexport type NoteLayout = 'legacy';\n\nexport type DecryptedNote = {\n  value: bigint;\n  blinding: Uint8Array; // 32 bytes BE\n  ownerPk: Uint8Array; // 32 bytes BE — recipient's master spending_pk\n  memo: Uint8Array | null; // 32 bytes when present, null otherwise\n  /// Always `null` in V1 (stealth withdrawn — see ADR-008 postmortem).\n  /// Field kept on the type so callers don't have to branch on a\n  /// missing property; the encryption layer enforces null.\n  stealthNonce: null;\n  /// Always `'legacy'` in V1.\n  layout: NoteLayout;\n};\n\n// ── Plaintext (de)serialisation ──────────────────────────────────────────\n\nfunction ensureLen(label: string, bytes: Uint8Array, expected: number): void {\n  if (bytes.length !== expected) {\n    throw new Error(`${label}: expected ${expected} bytes, got ${bytes.length}`);\n  }\n}\n\n/// Pack a `DecryptedNote` to its on-chain plaintext bytes.\n///\n/// Layout selection (auto):\n///   - `stealthNonce: null, memo: null` → 80B legacy (ADR-003 v1)\n///   - `memo: null` → 80B (no memo)\n///   - `memo: Uint8Array` → 112B (with memo)\nexport function serializeNotePlaintext(note: DecryptedNote): Uint8Array {\n  ensureLen('blinding', note.blinding, 32);\n  ensureLen('ownerPk', note.ownerPk, 32);\n  if (note.memo) ensureLen('memo', note.memo, 32);\n\n  const isWithMemo = !!note.memo;\n  const len = isWithMemo ? PLAINTEXT_WITH_MEMO : PLAINTEXT_NO_MEMO;\n  const out = new Uint8Array(len);\n\n  const view = new DataView(out.buffer, out.byteOffset, 8);\n  view.setBigUint64(0, note.value, true);\n  out.set(note.blinding, 8);\n  out.set(note.ownerPk, 40);\n  out[72] = isWithMemo ? 0x01 : 0x00;\n  if (isWithMemo) out.set(note.memo!, 80);\n  return out;\n}\n\n/// Inverse of `serializeNotePlaintext`. Throws on malformed inputs.\nexport function parseNotePlaintext(plaintext: Uint8Array): DecryptedNote {\n  const view = new DataView(plaintext.buffer, plaintext.byteOffset, plaintext.byteLength);\n  const flag = plaintext.length >= 73 ? plaintext[72] : 0xff;\n\n  if (plaintext.length === PLAINTEXT_NO_MEMO && flag === 0x00) {\n    return {\n      value: view.getBigUint64(0, true),\n      blinding: plaintext.slice(8, 40),\n      ownerPk: plaintext.slice(40, 72),\n      memo: null,\n      stealthNonce: null,\n      layout: 'legacy',\n    };\n  }\n  if (plaintext.length === PLAINTEXT_WITH_MEMO && flag === 0x01) {\n    return {\n      value: view.getBigUint64(0, true),\n      blinding: plaintext.slice(8, 40),\n      ownerPk: plaintext.slice(40, 72),\n      memo: plaintext.slice(80, 112),\n      stealthNonce: null,\n      layout: 'legacy',\n    };\n  }\n  throw new Error(\n    `note plaintext: unrecognized layout (length ${plaintext.length}, memo flag ${flag.toString(16)})`,\n  );\n}\n\n// ── Key agreement ────────────────────────────────────────────────────────\n\n/// HKDF-SHA256 with ADR-003 salt + commitment-as-info → 32-byte key.\nfunction deriveSymmetricKey(sharedSecret: Uint8Array, commitment: Uint8Array): Uint8Array {\n  return hkdf(sha256, sharedSecret, HKDF_SALT, commitment, 32);\n}\n\n/// `nonce = blake2b256(eph_pk || commitment)[:12]`.\nfunction deriveNonce(ephemeralPk: Uint8Array, commitment: Uint8Array): Uint8Array {\n  ensureLen('ephemeralPk', ephemeralPk, EPHEMERAL_PK_LEN);\n  ensureLen('commitment', commitment, COMMITMENT_LEN);\n  const concat = new Uint8Array(ephemeralPk.length + commitment.length);\n  concat.set(ephemeralPk, 0);\n  concat.set(commitment, ephemeralPk.length);\n  // dkLen 32 default; we slice the first 12 bytes for the AEAD nonce.\n  return blake2b(concat, { dkLen: 32 }).slice(0, NONCE_LEN);\n}\n\n// ── Encrypt / decrypt ────────────────────────────────────────────────────\n\nexport type EncryptedNoteBlob = {\n  /// X25519 ephemeral pubkey published with the blob. Pure scalar mult,\n  /// reveals nothing about the recipient.\n  ephemeralPk: Uint8Array; // 32 bytes\n  /// Plaintext + Poly1305 tag (96 or 128 bytes total).\n  ciphertext: Uint8Array;\n  /// Mirrors the on-chain commitment for AAD binding.\n  commitment: Uint8Array; // 32 bytes\n};\n\nexport type EncryptArgs = {\n  /// Plaintext note to encrypt — recipient's ownerPk + (optional)\n  /// `stealthNonce` are committed to inside.\n  note: DecryptedNote;\n  /// Recipient's X25519 viewing-key pubkey (per ADR-002).\n  recipientViewingPk: Uint8Array; // 32 bytes\n  /// Note commitment (Poseidon4 of value/owner_pk/blinding/memo). Used as\n  /// AEAD AAD + HKDF info — binds the ciphertext to a single commitment.\n  commitment: Uint8Array; // 32 bytes\n  /// Optional ephemeral keypair override (for deterministic test vectors).\n  /// Production code generates fresh randomness per note.\n  ephemeralKeypairOverride?: { sk: Uint8Array; pk: Uint8Array };\n};\n\n/// Encrypt a note for a recipient. Returns the publishable blob fields.\n///\n/// Domain-separation guarantees:\n///   - HKDF info=commitment makes the AEAD key unique per note (a leak\n///     of one key doesn't cascade)\n///   - AAD=commitment makes the ciphertext non-portable (can't be moved\n///     to a different commitment without breaking decryption)\n///   - Deterministic nonce (eph_pk + commitment) avoids the catastrophic\n///     \"weak browser entropy\" path; uniqueness is by construction since\n///     ephemeral_pk is fresh per call.\nexport function encryptNote(args: EncryptArgs): EncryptedNoteBlob {\n  ensureLen('recipientViewingPk', args.recipientViewingPk, 32);\n  ensureLen('commitment', args.commitment, COMMITMENT_LEN);\n\n  let ephSk: Uint8Array;\n  let ephPk: Uint8Array;\n  if (args.ephemeralKeypairOverride) {\n    ephSk = args.ephemeralKeypairOverride.sk;\n    ephPk = args.ephemeralKeypairOverride.pk;\n    ensureLen('ephemeralKeypairOverride.sk', ephSk, 32);\n    ensureLen('ephemeralKeypairOverride.pk', ephPk, 32);\n  } else {\n    ephSk = x25519.utils.randomPrivateKey();\n    ephPk = x25519.getPublicKey(ephSk);\n  }\n\n  const sharedSecret = x25519.getSharedSecret(ephSk, args.recipientViewingPk);\n  const key = deriveSymmetricKey(sharedSecret, args.commitment);\n  const nonce = deriveNonce(ephPk, args.commitment);\n\n  const plaintext = serializeNotePlaintext(args.note);\n  const cipher = chacha20poly1305(key, nonce, args.commitment);\n  const ciphertext = cipher.encrypt(plaintext);\n\n  if (\n    ciphertext.length !== CIPHERTEXT_NO_MEMO\n    && ciphertext.length !== CIPHERTEXT_WITH_MEMO\n  ) {\n    throw new Error(`encryptNote: unexpected ciphertext length ${ciphertext.length}`);\n  }\n  return {\n    ephemeralPk: ephPk,\n    ciphertext,\n    commitment: args.commitment,\n  };\n}\n\n/// Try to decrypt a blob with the given viewing secret. Returns the note\n/// when the auth tag verifies, or `null` when it doesn't (i.e. \"this\n/// blob isn't mine\"). Never throws on tag-mismatch — that's the\n/// expected hot path during note discovery.\nexport function tryDecryptNote(args: {\n  blob: EncryptedNoteBlob;\n  viewingSk: Uint8Array; // 32 bytes\n}): DecryptedNote | null {\n  ensureLen('viewingSk', args.viewingSk, 32);\n  ensureLen('blob.ephemeralPk', args.blob.ephemeralPk, 32);\n  ensureLen('blob.commitment', args.blob.commitment, COMMITMENT_LEN);\n  const len = args.blob.ciphertext.length;\n  if (len !== CIPHERTEXT_NO_MEMO && len !== CIPHERTEXT_WITH_MEMO) {\n    return null;\n  }\n\n  const sharedSecret = x25519.getSharedSecret(args.viewingSk, args.blob.ephemeralPk);\n  const key = deriveSymmetricKey(sharedSecret, args.blob.commitment);\n  const nonce = deriveNonce(args.blob.ephemeralPk, args.blob.commitment);\n  const cipher = chacha20poly1305(key, nonce, args.blob.commitment);\n  let plaintext: Uint8Array;\n  try {\n    plaintext = cipher.decrypt(args.blob.ciphertext);\n  } catch {\n    // Tag verification failed — not our blob, or corrupted.\n    return null;\n  }\n  try {\n    return parseNotePlaintext(plaintext);\n  } catch {\n    // Decrypted but plaintext layout is bad — bail rather than return\n    // garbage. Should never happen on a well-formed blob.\n    return null;\n  }\n}\n\n/// Strict variant: throws on auth-tag mismatch. Use this when the\n/// caller asserts the blob is theirs (e.g. after a successful trial-decrypt\n/// on a different blob, this one MUST match too).\nexport function decryptNote(args: { blob: EncryptedNoteBlob; viewingSk: Uint8Array }): DecryptedNote {\n  const out = tryDecryptNote(args);\n  if (!out) throw new Error('decryptNote: ciphertext failed Poly1305 verification');\n  return out;\n}\n\n// ── X25519 helpers (re-exported so callers don't import @noble directly) ─\n\n/// Generate a fresh X25519 keypair. Used for per-note ephemerals AND\n/// for the user's long-lived viewing key when initialising private balance.\nexport function generateX25519Keypair(): { sk: Uint8Array; pk: Uint8Array } {\n  const sk = x25519.utils.randomPrivateKey();\n  const pk = x25519.getPublicKey(sk);\n  return { sk, pk };\n}\n\n/// Derive an X25519 viewing pubkey from a 32-byte secret. The secret\n/// itself is produced by ADR-002's HKDF-from-wallet-sig path (see\n/// `private-pool-keys.ts`).\nexport function x25519PublicFromSecret(viewingSk: Uint8Array): Uint8Array {\n  ensureLen('viewingSk', viewingSk, 32);\n  return x25519.getPublicKey(viewingSk);\n}\n\n","// Private Pool V2 — wallet-derived key tree (ADR-002).\n//\n//   root_sk      = blake2b256(walletSig)[:32]\n//   spending_sk  = blake2b256(root_sk || ASCII(\"relai-pool-v2/spend\"))\n//   viewing_sk   = blake2b256(root_sk || ASCII(\"relai-pool-v2/view\"))\n//   nullifier_sk = blake2b256(root_sk || ASCII(\"relai-pool-v2/null\"))\n//\n//   spending_pk  = Poseidon1(spending_sk_field) mod p   // field element used in commitments\n//   viewing_pk   = X25519.public_from(viewing_sk)        // 32-byte X25519 pubkey\n//\n//   PaymentAddress = base58( spending_pk_bytes_BE || viewing_pk_bytes )\n//\n// `*_sk` is the raw 32-byte blake2b output. `*_sk_field` is the same\n// bytes reduced mod the BN254 scalar field modulus, then re-emitted as\n// a bigint suitable for Poseidon / circuit witness inputs. The two\n// representations are kept side by side because:\n//   - the encryption layer (ADR-003) wants raw bytes for X25519\n//   - the circuit witness (Faza 1) wants field elements\n\nimport { blake2b } from '@noble/hashes/blake2b';\nimport { x25519 } from '@noble/curves/ed25519';\nimport { buildPoseidon } from 'circomlibjs';\nimport bs58 from 'bs58';\n\nconst FIELD_MODULUS =\n  21888242871839275222246405745257275088548364400416034343698204186575808495617n;\n\nconst TEXT_ENCODER = new TextEncoder();\nconst LABELS = {\n  spending: TEXT_ENCODER.encode('relai-pool-v2/spend'),\n  viewing: TEXT_ENCODER.encode('relai-pool-v2/view'),\n  nullifier: TEXT_ENCODER.encode('relai-pool-v2/null'),\n} as const;\n\nconst PAYMENT_ADDRESS_BYTES = 64; // spending_pk(32) + viewing_pk(32)\n\n// ── Hash helpers ─────────────────────────────────────────────────────────\n\nfunction concatBytes(...parts: Uint8Array[]): Uint8Array {\n  const total = parts.reduce((sum, p) => sum + p.length, 0);\n  const out = new Uint8Array(total);\n  let off = 0;\n  for (const p of parts) {\n    out.set(p, off);\n    off += p.length;\n  }\n  return out;\n}\n\nfunction ensureBytes(label: string, bytes: Uint8Array, expected: number): void {\n  if (bytes.length !== expected) {\n    throw new Error(`${label}: expected ${expected} bytes, got ${bytes.length}`);\n  }\n}\n\n/// 32-byte BE → bigint (reduced mod BN254 scalar field).\nexport function bytesToFieldBE(bytes: Uint8Array): bigint {\n  if (bytes.length === 0) return 0n;\n  let v = 0n;\n  for (const b of bytes) v = (v << 8n) + BigInt(b);\n  return ((v % FIELD_MODULUS) + FIELD_MODULUS) % FIELD_MODULUS;\n}\n\n/// bigint → 32-byte BE buffer. Throws if the bigint exceeds 256 bits\n/// (shouldn't happen for field elements, but worth being explicit).\nexport function fieldToBytes32BE(value: bigint): Uint8Array {\n  if (value < 0n) {\n    value = ((value % FIELD_MODULUS) + FIELD_MODULUS) % FIELD_MODULUS;\n  }\n  const hex = value.toString(16).padStart(64, '0');\n  if (hex.length > 64) {\n    throw new Error(`fieldToBytes32BE: value exceeds 32 bytes (${hex.length / 2})`);\n  }\n  return Uint8Array.from(Buffer.from(hex, 'hex'));\n}\n\n// ── Poseidon singleton ───────────────────────────────────────────────────\n\nlet poseidonInstance: ReturnType<typeof buildPoseidon> extends Promise<infer T> ? T | null : never = null;\nasync function getPoseidon() {\n  if (!poseidonInstance) {\n    poseidonInstance = await buildPoseidon();\n  }\n  return poseidonInstance!;\n}\n\n/// Poseidon-Bn254X5 of one field input → bigint mod p.\nexport async function poseidon1(input: bigint): Promise<bigint> {\n  // circomlibjs has no published .d.ts so the `buildPoseidon` return is\n  // typed as `unknown` upstream. The runtime shape is well-known and\n  // stable; cast to `any` for the call sites.\n  const p = (await getPoseidon()) as any;\n  const h = p([input]);\n  return ((BigInt(p.F.toString(h)) % FIELD_MODULUS) + FIELD_MODULUS) % FIELD_MODULUS;\n}\n\n// ── Sub-key derivation ───────────────────────────────────────────────────\n\n/// `root_sk = blake2b256(walletSig)[:32]`. The wallet signature is\n/// produced off-screen by signing the canonical challenge message\n/// (`PRIVATE_POOL_V2_KEY_CHALLENGE`) — the caller is expected to handle\n/// the wallet popup itself.\nexport function deriveRootSk(walletSignature: Uint8Array): Uint8Array {\n  if (!walletSignature || walletSignature.length === 0) {\n    throw new Error('deriveRootSk: walletSignature must be non-empty');\n  }\n  return blake2b(walletSignature, { dkLen: 32 });\n}\n\n/// `<sub_sk> = blake2b256(root_sk || label)`.\nexport function deriveSubkey(rootSk: Uint8Array, label: Uint8Array): Uint8Array {\n  ensureBytes('rootSk', rootSk, 32);\n  return blake2b(concatBytes(rootSk, label), { dkLen: 32 });\n}\n\nexport type PrivatePoolKeyMaterial = {\n  /// Raw 32-byte blake2b sub-keys.\n  rootSk: Uint8Array;\n  spendingSk: Uint8Array;\n  viewingSk: Uint8Array;\n  nullifierSk: Uint8Array;\n  /// Field-element views of the spending/nullifier secrets — what the\n  /// circuit witness consumes. spending_sk_field / nullifier_sk_field\n  /// are `bytesToFieldBE(spendingSk)` etc.\n  spendingSkField: bigint;\n  nullifierSkField: bigint;\n  /// Public counterparts.\n  spendingPkField: bigint;        // Poseidon1(spendingSkField)\n  viewingPk: Uint8Array;          // X25519.public(viewingSk)\n  /// Encoded payment address, base58( spending_pk_bytes || viewing_pk_bytes ).\n  paymentAddress: string;\n};\n\n/// One-shot key derivation. Returns the full sub-key tree + payment\n/// address. Idempotent; safe to re-run on every page load.\nexport async function derivePrivatePoolKeys(\n  walletSignature: Uint8Array,\n): Promise<PrivatePoolKeyMaterial> {\n  const rootSk = deriveRootSk(walletSignature);\n  const spendingSk = deriveSubkey(rootSk, LABELS.spending);\n  const viewingSk = deriveSubkey(rootSk, LABELS.viewing);\n  const nullifierSk = deriveSubkey(rootSk, LABELS.nullifier);\n\n  const spendingSkField = bytesToFieldBE(spendingSk);\n  const nullifierSkField = bytesToFieldBE(nullifierSk);\n  const spendingPkField = await poseidon1(spendingSkField);\n  const viewingPk = x25519.getPublicKey(viewingSk);\n\n  const paymentAddress = encodePaymentAddress(spendingPkField, viewingPk);\n\n  return {\n    rootSk,\n    spendingSk,\n    viewingSk,\n    nullifierSk,\n    spendingSkField,\n    nullifierSkField,\n    spendingPkField,\n    viewingPk,\n    paymentAddress,\n  };\n}\n\n// ── F2: code-derived key material (private payment codes) ──────────────\n//\n// A one-time `code_seed` (32 bytes) deterministically generates the\n// same key tree as a wallet signature would. The seed plays exactly\n// the role of `walletSignature` in `derivePrivatePoolKeys` — same\n// blake2b labels, same Poseidon derivation, same X25519 viewing key.\n//\n// Why we reuse the existing tree shape:\n//   - The credit-pool leaf binds `owner_pk = spending_pk_field`.\n//     Whatever generates `spending_sk_field` and lets us compute its\n//     Poseidon-1 image works as a \"recipient identity.\" The wallet\n//     case derives via signature; the bearer-code case derives via\n//     seed. Same circuit, same prover, same on-chain verifier.\n//   - The encrypted note blob is keyed by `viewing_pk` (X25519). The\n//     redeemer needs to derive the matching `viewing_sk` from the\n//     same seed at redeem time.\n//\n// Why this is safe:\n//   - The seed is generated client-side and never leaves the issuer's\n//     device (except via the URL/QR they hand to the redeemer).\n//   - Anyone with the seed can re-derive all keys and cash out the\n//     parked leaf. That's exactly the bearer-token semantics we want.\n//   - The seed is large enough (32 bytes = 256 bits) that brute-force\n//     guessing the seed of a specific leaf is not feasible.\n//\n// Important difference from `derivePrivatePoolKeys`:\n//   - No `paymentAddress` is computed — the seed itself encodes both\n//     halves of the addressing, and we don't want to leak a payment\n//     address that ties the code to a long-lived identity.\n\n/// Two seed formats, picked by the issuer per code:\n///\n///   • `long` (default; 32B / 256-bit)\n///     - Encoded as base58 in URL fragment (~44 chars).\n///     - Astronomically brute-force resistant. Industry-standard for\n///       \"fire and forget\" bearer tokens.\n///     - Hard to dictate by voice/SMS — must be shared as URL/QR.\n///\n///   • `short` (16-char Wi-Fi-style base32, 80-bit)\n///     - Encoded as `XXXX-XXXX-XXXX-XXXX` for easy reading.\n///     - ~80 bits of entropy. At 10⁹ hashes/s a single specific code\n///       takes ≈ 38,000 years to brute-force; the on-chain leaf set\n///       gates each guess by Poseidon-derived owner_pk lookup, so\n///       practical attack cost is much higher. Comfortable for typical\n///       consumer amounts; we'd recommend `long` for institutional /\n///       very high value.\n///     - Can be dictated, written on paper, typed into a separate\n///       device — same UX feel as a Wi-Fi password.\n///\n/// Both share the SAME key-derivation algorithm — we just hand\n/// `deriveRootSk` a different number of entropy bytes. The on-chain\n/// proof is identical for both formats; format choice is purely an\n/// issuer-side UX dial.\nconst LONG_SEED_BYTES = 32;\nconst SHORT_SEED_BYTES = 10; // 10 × 8 = 80 bits, encodes as 16 base32 chars\n\nexport type CodeSeedFormat = 'short' | 'long';\n\nexport type CodeKeyMaterial = {\n  /// Raw seed; the only thing the issuer must guard + later share.\n  /// 10 bytes for `short`, 32 bytes for `long`. The on-chain derivation\n  /// works identically either way (blake2b is variable-input).\n  codeSeed: Uint8Array;\n  /// Which format the seed was generated as. Lets callers re-encode\n  /// the same seed back to the right surface (base32 vs base58)\n  /// without recomputing entropy.\n  format: CodeSeedFormat;\n  /// Key tree, identical shape to wallet-derived material.\n  rootSk: Uint8Array;\n  spendingSk: Uint8Array;\n  viewingSk: Uint8Array;\n  nullifierSk: Uint8Array;\n  spendingSkField: bigint;\n  nullifierSkField: bigint;\n  spendingPkField: bigint;\n  viewingPk: Uint8Array;\n};\n\n/// Generate a fresh code seed using a CSPRNG. The issuer holds this\n/// locally (in IndexedDB if they want cancel capability) and shares\n/// it out-of-band — usually as part of a URL fragment so it never\n/// touches the server.\n///\n/// `format` defaults to `'long'` for safety. Callers wanting the\n/// dictatable Wi-Fi-style format pass `'short'` explicitly.\nexport function generateCodeSeed(format: CodeSeedFormat = 'long'): Uint8Array {\n  const bytes = format === 'short' ? SHORT_SEED_BYTES : LONG_SEED_BYTES;\n  const out = new Uint8Array(bytes);\n  // `crypto` is the same global the rest of the SDK uses for\n  // blinding randomness; available in both browser and Node ≥ 19.\n  crypto.getRandomValues(out);\n  return out;\n}\n\n/// Detect the format of an existing seed by its length. Useful when a\n/// caller has the bytes but not the format flag (e.g. after re-deriving\n/// from a decoded code).\nexport function detectCodeSeedFormat(codeSeed: Uint8Array): CodeSeedFormat {\n  if (codeSeed.length === SHORT_SEED_BYTES) return 'short';\n  if (codeSeed.length === LONG_SEED_BYTES) return 'long';\n  throw new Error(\n    `detectCodeSeedFormat: unsupported seed length ${codeSeed.length} (expected ${SHORT_SEED_BYTES} or ${LONG_SEED_BYTES})`,\n  );\n}\n\n/// Deterministic key derivation from a code seed. Same algorithm as\n/// the wallet-derived path; the only difference is the entropy source\n/// (seed vs wallet signature). Idempotent — calling it twice with the\n/// same seed yields exactly the same key material.\n///\n/// Accepts both short (10-byte) and long (32-byte) seeds; the\n/// `blake2b(seed)` rooting step doesn't care about input length, and\n/// downstream derivation is identical.\nexport async function deriveCodeKeys(\n  codeSeed: Uint8Array,\n): Promise<CodeKeyMaterial> {\n  if (!codeSeed) {\n    throw new Error('deriveCodeKeys: codeSeed required');\n  }\n  if (codeSeed.length !== SHORT_SEED_BYTES && codeSeed.length !== LONG_SEED_BYTES) {\n    throw new Error(\n      `deriveCodeKeys: codeSeed must be ${SHORT_SEED_BYTES} or ${LONG_SEED_BYTES} bytes (got ${codeSeed.length})`,\n    );\n  }\n  const format: CodeSeedFormat = codeSeed.length === SHORT_SEED_BYTES ? 'short' : 'long';\n  const rootSk = deriveRootSk(codeSeed);\n  const spendingSk = deriveSubkey(rootSk, LABELS.spending);\n  const viewingSk = deriveSubkey(rootSk, LABELS.viewing);\n  const nullifierSk = deriveSubkey(rootSk, LABELS.nullifier);\n\n  const spendingSkField = bytesToFieldBE(spendingSk);\n  const nullifierSkField = bytesToFieldBE(nullifierSk);\n  const spendingPkField = await poseidon1(spendingSkField);\n  const viewingPk = x25519.getPublicKey(viewingSk);\n\n  return {\n    codeSeed,\n    format,\n    rootSk,\n    spendingSk,\n    viewingSk,\n    nullifierSk,\n    spendingSkField,\n    nullifierSkField,\n    spendingPkField,\n    viewingPk,\n  };\n}\n\n// ── F2: code-seed codec for URLs (Bech32-style) ────────────────────────\n//\n// We encode the 32B seed as base58 — same alphabet the SDK already uses\n// for the payment address, no new dependencies, ~44 ASCII chars. The\n// short fixed length lets us validate decoded seeds with a single\n// length check. A Bech32 checksum is overkill at this size (a typo\n// would already fail the field-element derivation at park-redeem time,\n// just less helpfully) — kept simple to reduce dependency surface.\n\n/// Encode a 32B \"long\" seed for URL / QR transport. Result is base58\n/// of the raw seed bytes — no version byte, no checksum. The caller\n/// is expected to put this in the URL fragment (`#seed=…`) so it never\n/// hits server logs.\nexport function encodeCodeSeed(seed: Uint8Array): string {\n  if (!seed || seed.length !== LONG_SEED_BYTES) {\n    throw new Error(\n      `encodeCodeSeed: seed must be exactly ${LONG_SEED_BYTES} bytes (got ${seed?.length ?? 0})`,\n    );\n  }\n  const encoder = (bs58 as { encode?: (b: Uint8Array) => string }).encode\n    ?? (bs58 as { default?: { encode: (b: Uint8Array) => string } }).default?.encode;\n  if (!encoder) {\n    throw new Error('encodeCodeSeed: bs58 encoder missing — incompatible bs58 version');\n  }\n  return encoder(seed);\n}\n\n/// Inverse of `encodeCodeSeed`. Throws on malformed input (wrong\n/// length, non-base58 chars). The caller may want to wrap this in a\n/// try/catch to surface a user-friendly \"invalid code\" error.\nexport function decodeCodeSeed(encoded: string): Uint8Array {\n  if (typeof encoded !== 'string' || encoded.length === 0) {\n    throw new Error('decodeCodeSeed: input must be a non-empty string');\n  }\n  const decoder = (bs58 as { decode?: (s: string) => Uint8Array }).decode\n    ?? (bs58 as { default?: { decode: (s: string) => Uint8Array } }).default?.decode;\n  if (!decoder) {\n    throw new Error('decodeCodeSeed: bs58 decoder missing — incompatible bs58 version');\n  }\n  const out = decoder(encoded);\n  if (out.length !== LONG_SEED_BYTES) {\n    throw new Error(\n      `decodeCodeSeed: decoded ${out.length} bytes, expected ${LONG_SEED_BYTES}`,\n    );\n  }\n  return out;\n}\n\n// ── Short-code (Wi-Fi-style base32) codec ──────────────────────────────\n//\n// 10 bytes → 16 base32 characters → rendered as `XXXX-XXXX-XXXX-XXXX`.\n// RFC 4648 base32 alphabet (`A-Z2-7`) is deliberately confusion-free —\n// no 0/O, 1/I/l ambiguity. Issuer can dictate the code over the phone\n// without losing 25 minutes to \"is that an O or zero\" rounds. Dashes\n// are decorative; the decoder strips them.\n//\n// Why base32 over base58 here:\n//   • 5 bits per character → fixed 16-char output for 80 bits, with\n//     no awkward \"≈ 14 chars\" rounding.\n//   • Voice-friendly alphabet.\n//   • Dashes optional in the input — we tolerate either form.\n\nconst BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';\n\nfunction encodeBase32(bytes: Uint8Array): string {\n  let bits = 0;\n  let value = 0;\n  let out = '';\n  for (const b of bytes) {\n    value = (value << 8) | b;\n    bits += 8;\n    while (bits >= 5) {\n      out += BASE32_ALPHABET[(value >>> (bits - 5)) & 0x1f];\n      bits -= 5;\n    }\n  }\n  if (bits > 0) {\n    out += BASE32_ALPHABET[(value << (5 - bits)) & 0x1f];\n  }\n  return out;\n}\n\nfunction decodeBase32(s: string): Uint8Array {\n  // Normalise: uppercase, drop dashes/whitespace, validate alphabet.\n  const cleaned = s.toUpperCase().replace(/[\\s-]/g, '');\n  if (cleaned.length === 0) throw new Error('decodeBase32: empty input');\n  for (const ch of cleaned) {\n    if (BASE32_ALPHABET.indexOf(ch) === -1) {\n      throw new Error(`decodeBase32: illegal character '${ch}' (allowed: A-Z, 2-7)`);\n    }\n  }\n  let bits = 0;\n  let value = 0;\n  const out: number[] = [];\n  for (const ch of cleaned) {\n    value = (value << 5) | BASE32_ALPHABET.indexOf(ch);\n    bits += 5;\n    if (bits >= 8) {\n      out.push((value >>> (bits - 8)) & 0xff);\n      bits -= 8;\n    }\n  }\n  return Uint8Array.from(out);\n}\n\n/// Format a raw 10-byte seed into the user-facing\n/// `XXXX-XXXX-XXXX-XXXX` representation. Round-trip with\n/// `decodeShortCode` is exact.\nexport function encodeShortCode(seed: Uint8Array): string {\n  if (!seed || seed.length !== SHORT_SEED_BYTES) {\n    throw new Error(\n      `encodeShortCode: seed must be exactly ${SHORT_SEED_BYTES} bytes (got ${seed?.length ?? 0})`,\n    );\n  }\n  const flat = encodeBase32(seed); // 16 chars\n  // Insert a hyphen every 4 chars for readability. `XXXX-XXXX-XXXX-XXXX`.\n  return flat.match(/.{1,4}/g)!.join('-');\n}\n\n/// Inverse of `encodeShortCode`. Accepts the canonical\n/// `XXXX-XXXX-XXXX-XXXX` form, plain `XXXXXXXXXXXXXXXX`, lower-case,\n/// or any combination with extra whitespace — anything a user might\n/// dictate or paste with mild messiness. Throws on illegal characters\n/// or wrong length.\nexport function decodeShortCode(encoded: string): Uint8Array {\n  if (typeof encoded !== 'string') {\n    throw new Error('decodeShortCode: input must be a string');\n  }\n  const out = decodeBase32(encoded);\n  if (out.length !== SHORT_SEED_BYTES) {\n    throw new Error(\n      `decodeShortCode: decoded ${out.length} bytes, expected ${SHORT_SEED_BYTES} (16 base32 chars)`,\n    );\n  }\n  return out;\n}\n\n/// Auto-detect the format of an encoded code string and return the raw\n/// seed bytes. Convenience for redeem flows that accept either format\n/// from a single text input.\n///\n/// Detection rules:\n///   • Strip whitespace + dashes. If the result is exactly 16 chars in\n///     the base32 alphabet → short code, decoded as such.\n///   • Otherwise try base58 → 32-byte long seed.\n///   • Anything else → throw with a hint.\nexport function decodeCodeAuto(encoded: string): {\n  seed: Uint8Array;\n  format: CodeSeedFormat;\n} {\n  if (typeof encoded !== 'string' || encoded.trim().length === 0) {\n    throw new Error('decodeCodeAuto: empty input');\n  }\n  const cleaned = encoded.trim();\n  const base32Cleaned = cleaned.toUpperCase().replace(/[\\s-]/g, '');\n  // Short code: exactly 16 base32 chars.\n  if (\n    base32Cleaned.length === 16\n    && [...base32Cleaned].every((c) => BASE32_ALPHABET.indexOf(c) !== -1)\n  ) {\n    return { seed: decodeShortCode(cleaned), format: 'short' };\n  }\n  // Otherwise assume base58 long. The base58 decoder will throw if\n  // the alphabet doesn't match.\n  try {\n    return { seed: decodeCodeSeed(cleaned), format: 'long' };\n  } catch (err) {\n    throw new Error(\n      `decodeCodeAuto: not a valid short (16 char) or long (~44 char) code: ${err instanceof Error ? err.message : String(err)}`,\n    );\n  }\n}\n\n// ── Payment address codec ────────────────────────────────────────────────\n\n/// `base58(spending_pk_bytes_BE || viewing_pk_bytes)` — the human-shareable\n/// \"send notes to me\" handle. 64 bytes encoded → ~88 base58 chars.\nexport function encodePaymentAddress(spendingPkField: bigint, viewingPk: Uint8Array): string {\n  ensureBytes('viewingPk', viewingPk, 32);\n  const spendingPkBytes = fieldToBytes32BE(spendingPkField);\n  const concat = concatBytes(spendingPkBytes, viewingPk);\n  // bs58@4: bs58.encode(buffer)\n  // bs58@5+: bs58.default.encode(buffer)\n  const encoder = (bs58 as { encode?: (b: Uint8Array) => string }).encode\n    ?? (bs58 as { default?: { encode: (b: Uint8Array) => string } }).default?.encode;\n  if (!encoder) throw new Error('encodePaymentAddress: bs58.encode not found');\n  return encoder(concat);\n}\n\nexport function parsePaymentAddress(address: string): {\n  spendingPkField: bigint;\n  viewingPk: Uint8Array;\n} {\n  const decoder = (bs58 as { decode?: (s: string) => Uint8Array }).decode\n    ?? (bs58 as { default?: { decode: (s: string) => Uint8Array } }).default?.decode;\n  if (!decoder) throw new Error('parsePaymentAddress: bs58.decode not found');\n  const decoded = decoder(address);\n  if (decoded.length !== PAYMENT_ADDRESS_BYTES) {\n    throw new Error(\n      `parsePaymentAddress: decoded length ${decoded.length} != ${PAYMENT_ADDRESS_BYTES}`,\n    );\n  }\n  return {\n    spendingPkField: bytesToFieldBE(decoded.slice(0, 32)),\n    viewingPk: decoded.slice(32, 64),\n  };\n}\n\n// ── Wallet challenge ─────────────────────────────────────────────────────\n\n/// Canonical challenge message the wallet signs to derive the root_sk.\n/// Versioned + namespaced so a sig made for V2 can never be reused for\n/// any other RelAI feature (or any other app's \"sign this\" flow).\nexport const PRIVATE_POOL_V2_KEY_CHALLENGE =\n  'RelAI Private Pool V2 — derive private balance keys.\\n\\nThis signature unlocks your hidden-amount balance under the /app/private surface. It does NOT authorize any spend on its own.';\n\n/// Build the wallet challenge bytes for `signMessage` (UTF-8 of the\n/// human-readable challenge above; matches what the on-screen prompt\n/// will display).\nexport function buildKeyChallengeMessage(): Uint8Array {\n  return TEXT_ENCODER.encode(PRIVATE_POOL_V2_KEY_CHALLENGE);\n}\n"],"mappings":"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","names":["import_circomlibjs","import_blake2b","import_ed25519","FIELD_MODULUS"]}