diff --git a/CHANGELOG.md b/CHANGELOG.md index 971b514f..e01b89ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Changes +* [BREAKING][web] `client.notes.sendPrivate({ note, to })` now requires an explicit `scanAfterBlockNum` — the block the recipient scans **forward** from for the note's on-chain commitment — rather than the SDK inferring it from the client's current sync height. A hint above the commitment is never scanned back to, so the previous sync-height inference silently dropped delivery once the sender had synced past the note (e.g. relaying after waiting for the transaction to commit). The value must be at or below the commitment block; a safe choice is the chain tip when the note's transaction was submitted. For one of this client's own output notes, prefer the new `client.notes.sendPrivateOutput({ noteId, to })`, which derives that block from the note's stored expected height for you. Refines the hint added in [web-sdk#258](https://github.com/0xMiden/web-sdk/pull/258). ([web-sdk#264](https://github.com/0xMiden/web-sdk/pull/264), closes [#262](https://github.com/0xMiden/web-sdk/issues/262)) * [BREAKING][web] Transaction submissions now encrypt their private inputs, so the RPC operator relaying them cannot read them: only holders of the validator set's shared encryption secret can. On first submission the client fetches the validator set's transaction encryption key from the node, verifies a validator attestation for it against the validator set committed in a trusted block header (bound to the chain's genesis commitment, so an attestation cannot be replayed from another network), and caches the verified key in the store; a submission rejected for having been sealed against a retired key evicts the cached key and the next submission re-fetches. Requires a node that unseals submitted inputs — such nodes reject plaintext submissions, and older nodes reject sealed ones, so client and node must be upgraded together. ([#252](https://github.com/0xMiden/web-sdk/pull/252), client [#2341](https://github.com/0xMiden/rust-sdk/pull/2341)) * [BREAKING][web] Removed `notes.fetchPrivate({ mode: "all" })` (`WasmWebClient.fetchAllPrivateNotes`). `fetchPrivate()` now takes no arguments and always fetches incrementally from the stored pagination cursor. The full re-scan is no longer needed: historical notes for a newly tracked tag sit below the shared cursor and are now backfilled automatically during `sync()`, one tag at a time, so callers that previously reached for `mode: "all"` after adding a tag should just sync. Callers passing the option get a type error; the argument is otherwise ignored at runtime. * [BREAKING][web] Removed `FungibleAsset.withCallbacks(flag)`. The callback flag is no longer a per-asset value: it is an immutable property of the issuing faucet's account id, so every asset from a given faucet carries the same flag and no copy can override it. `FungibleAsset.callbacks()` still reports the flag, now read from the faucet id. Callers that built an asset with an explicit flag should drop the call — the flag follows the faucet automatically. Forward-ported from the 0.15 line ([web-sdk#240](https://github.com/0xMiden/web-sdk/pull/240)), where the flag was settable. diff --git a/crates/web-client/js/__tests__/resources/notes.test.js b/crates/web-client/js/__tests__/resources/notes.test.js index 278c178b..cde500ad 100644 --- a/crates/web-client/js/__tests__/resources/notes.test.js +++ b/crates/web-client/js/__tests__/resources/notes.test.js @@ -41,6 +41,7 @@ function makeInner() { exportNoteFile: vi.fn(), fetchPrivateNotes: vi.fn(), sendPrivateNote: vi.fn(), + sendPrivateOutputNote: vi.fn(), }; } @@ -285,18 +286,23 @@ describe("NotesResource", () => { }); describe("sendPrivate", () => { - it("sends a Note object directly (has id() and assets(), no toNote())", async () => { + it("sends a Note object directly with the explicit scan-after block", async () => { inner.sendPrivateNote.mockResolvedValue(undefined); const noteObj = { id: vi.fn().mockReturnValue({ toString: () => "noteid" }), assets: vi.fn(), }; const resource = makeResource(); - await resource.sendPrivate({ note: noteObj, to: "0xrecipient" }); + await resource.sendPrivate({ + note: noteObj, + to: "0xrecipient", + scanAfterBlockNum: 7, + }); expect(client.assertNotTerminated).toHaveBeenCalledOnce(); expect(inner.sendPrivateNote).toHaveBeenCalledWith( noteObj, - expect.anything() + expect.anything(), + 7 ); }); @@ -308,20 +314,40 @@ describe("NotesResource", () => { inner.getInputNote.mockResolvedValue(record); inner.sendPrivateNote.mockResolvedValue(undefined); const resource = makeResource(); - await resource.sendPrivate({ note: "0xnoteHex", to: "0xrecipient" }); + await resource.sendPrivate({ + note: "0xnoteHex", + to: "0xrecipient", + scanAfterBlockNum: 3, + }); expect(inner.getInputNote).toHaveBeenCalledWith("0xnoteHex"); expect(record.toNote).toHaveBeenCalledOnce(); expect(inner.sendPrivateNote).toHaveBeenCalledWith( note, - expect.anything() + expect.anything(), + 3 ); }); + it("throws when scanAfterBlockNum is missing", async () => { + const resource = makeResource(); + await expect( + resource.sendPrivate({ + note: { id: vi.fn(), assets: vi.fn() }, + to: "0xrec", + }) + ).rejects.toThrow("scanAfterBlockNum"); + expect(inner.sendPrivateNote).not.toHaveBeenCalled(); + }); + it("throws when note not found by hex", async () => { inner.getInputNote.mockResolvedValue(undefined); const resource = makeResource(); await expect( - resource.sendPrivate({ note: "0xmissing", to: "0xrec" }) + resource.sendPrivate({ + note: "0xmissing", + to: "0xrec", + scanAfterBlockNum: 1, + }) ).rejects.toThrow("Note not found: 0xmissing"); }); @@ -335,6 +361,33 @@ describe("NotesResource", () => { await resource.sendPrivate({ note: noteObj, to: "mBech32Address", + scanAfterBlockNum: 0, + }); + expect(wasm.Address.fromBech32).toHaveBeenCalledWith("mBech32Address"); + }); + }); + + describe("sendPrivateOutput", () => { + it("relays an output note by id (SDK derives the block from expected height)", async () => { + inner.sendPrivateOutputNote.mockResolvedValue(undefined); + const resource = makeResource(); + await resource.sendPrivateOutput({ + noteId: "0xoutputNote", + to: "0xrecipient", + }); + expect(client.assertNotTerminated).toHaveBeenCalledOnce(); + expect(inner.sendPrivateOutputNote).toHaveBeenCalledWith( + "0xoutputNote", + expect.anything() + ); + }); + + it("resolves bech32 'to' address", async () => { + inner.sendPrivateOutputNote.mockResolvedValue(undefined); + const resource = makeResource(); + await resource.sendPrivateOutput({ + noteId: "0xoutputNote", + to: "mBech32Address", }); expect(wasm.Address.fromBech32).toHaveBeenCalledWith("mBech32Address"); }); diff --git a/crates/web-client/js/index.js b/crates/web-client/js/index.js index a363391e..70370b48 100644 --- a/crates/web-client/js/index.js +++ b/crates/web-client/js/index.js @@ -104,6 +104,7 @@ const WRITE_METHODS = new Set([ "removeTag", "removeSetting", "sendPrivateNote", + "sendPrivateOutputNote", "setSetting", "submitNewTransactionBatch", "submitProvenTransaction", diff --git a/crates/web-client/js/resources/notes.js b/crates/web-client/js/resources/notes.js index db7b4a79..8eea1147 100644 --- a/crates/web-client/js/resources/notes.js +++ b/crates/web-client/js/resources/notes.js @@ -64,6 +64,18 @@ export class NotesResource { this.#client.assertNotTerminated(); const wasm = await this.#getWasm(); + if ( + !Number.isInteger(opts?.scanAfterBlockNum) || + opts.scanAfterBlockNum < 0 + ) { + throw new Error( + "sendPrivate requires scanAfterBlockNum: the block the recipient scans forward " + + "from for the note's commitment. It must be at or below the commitment block. " + + "For one of this client's own output notes, use sendPrivateOutput({ noteId, to }) " + + "which derives this from the note's expected height." + ); + } + let note; const input = opts.note; // Check if input is a Note object (has .id() and .assets() but not .toNote()) @@ -85,7 +97,15 @@ export class NotesResource { } const address = resolveAddress(opts.to, wasm); - await this.#inner.sendPrivateNote(note, address); + await this.#inner.sendPrivateNote(note, address, opts.scanAfterBlockNum); + } + + async sendPrivateOutput(opts) { + this.#client.assertNotTerminated(); + const wasm = await this.#getWasm(); + const noteHex = resolveNoteIdHex(opts.noteId); + const address = resolveAddress(opts.to, wasm); + await this.#inner.sendPrivateOutputNote(noteHex, address); } } diff --git a/crates/web-client/js/types/api-types.d.ts b/crates/web-client/js/types/api-types.d.ts index 3e1506a8..708dbc89 100644 --- a/crates/web-client/js/types/api-types.d.ts +++ b/crates/web-client/js/types/api-types.d.ts @@ -816,7 +816,24 @@ export interface ExportNoteOptions { } export interface SendPrivateOptions { + /** The note to relay — a `Note`, or a note id/record resolved from this client's input notes. */ note: NoteInput; + /** The recipient. */ + to: AccountRef; + /** + * Block the recipient scans FORWARD from for the note's on-chain commitment. Must be at or below + * the commitment block — a hint above it is never scanned back to, so the recipient silently + * never receives the note. A safe, always-valid choice is the chain tip when the note's + * transaction was submitted. For one of this client's own output notes, prefer `sendPrivateOutput`, + * which derives this block for you. + */ + scanAfterBlockNum: number; +} + +export interface SendPrivateOutputOptions { + /** Id of one of this client's own output notes (its transaction must have been applied). */ + noteId: NoteInput; + /** The recipient. */ to: AccountRef; } @@ -1227,11 +1244,29 @@ export interface NotesResource { */ fetchPrivate(): Promise; /** - * Send a private note to a recipient via the note transport service. + * Relay a private note to a recipient via the note transport service, with an explicit block + * hint (`scanAfterBlockNum`) the recipient scans forward from for the note's on-chain commitment. * - * @param options - Options including the note and the recipient. + * The hint must be at or below the commitment block; a hint above it is never scanned back to and + * the recipient silently never receives the note. This is the agnostic form for relaying an + * arbitrary note; for one of this client's own output notes prefer {@link NotesResource.sendPrivateOutput}, + * which derives the block from the note's stored expected height. + * + * @param options - The note, the recipient, and `scanAfterBlockNum`. */ sendPrivate(options: SendPrivateOptions): Promise; + /** + * Relay one of this client's own private output notes via the note transport service. + * + * The recipient's scan-start block is derived from the note's stored `expected_height` (the chain + * tip when its transaction was submitted), so delivery is correct regardless of how far this + * client has since synced past the note — a bare sync-height hint would overshoot the commitment + * once the sender advances past it (e.g. relaying after waiting for commit) and silently drop + * delivery. The note must exist in this client's store as an output note. + * + * @param options - The output note id and the recipient. + */ + sendPrivateOutput(options: SendPrivateOutputOptions): Promise; } // ════════════════════════════════════════════════════════════════ diff --git a/crates/web-client/src/note_transport.rs b/crates/web-client/src/note_transport.rs index 3aeb3bff..6b33614d 100644 --- a/crates/web-client/src/note_transport.rs +++ b/crates/web-client/src/note_transport.rs @@ -1,37 +1,92 @@ use js_export_macro::js_export; +use miden_client::Word; +use miden_client::note::{Note as NativeNote, NoteId}; use crate::platform::{JsErr, from_str_err}; use crate::{WebClient, js_error_with_context}; #[js_export] impl WebClient { - /// Send a private note via the note transport layer + /// Relay a private note through the note-transport layer with an explicit block hint. + /// + /// `scan_after_block_num` is the block from which the recipient starts scanning FORWARD for the + /// note's on-chain commitment. It MUST be at or below the note's commitment block — a hint + /// above the commitment is never scanned back to, so the recipient silently never receives + /// the note. A safe, always-valid choice is the chain tip at the moment the note's + /// transaction was submitted (the note cannot have committed earlier); a tighter value just + /// means the recipient scans fewer blocks. + /// + /// For one of this client's own output notes, prefer [`WebClient::send_private_output_note`], + /// which derives this block from the note's stored `expected_height` for you. #[js_export(js_name = "sendPrivateNote")] pub async fn send_private_note( &self, note: crate::models::note::Note, address: crate::models::address::Address, + scan_after_block_num: u32, ) -> Result<(), JsErr> { let mut guard = self.get_mut_inner().await; let client = guard .as_mut() .ok_or_else(|| from_str_err("Client not initialized. Call createClient() first."))?; - // Relay the client's current sync height as the block hint so the recipient gets - // deterministic delivery (scanning from that block) instead of a fixed lookback window. - let block_hint = client - .get_sync_height() - .await - .map_err(|e| js_error_with_context(e, "failed to read sync height"))?; + let native_note: NativeNote = note.into(); client - .send_private_note_with_block_hint(note.into(), &address.into(), block_hint) + .send_private_note_with_block_hint( + native_note, + &address.into(), + scan_after_block_num.into(), + ) .await .map_err(|e| js_error_with_context(e, "failed sending private note"))?; Ok(()) } + /// Relay one of this client's own private output notes through the note-transport layer. + /// + /// The recipient's scan-start block is derived from the output note's stored `expected_height` + /// (the chain tip when the note's transaction was submitted), so delivery is correct regardless + /// of how far this client has since synced past the note — unlike a bare sync-height hint, + /// which overshoots the commitment once the sender advances past it (e.g. relaying after + /// waiting for the transaction to commit) and silently drops delivery. The note must exist + /// in this client's store as an output note (i.e. its transaction has been applied). + #[js_export(js_name = "sendPrivateOutputNote")] + pub async fn send_private_output_note( + &self, + note_id: String, + address: crate::models::address::Address, + ) -> Result<(), JsErr> { + let mut guard = self.get_mut_inner().await; + let client = guard + .as_mut() + .ok_or_else(|| from_str_err("Client not initialized. Call createClient() first."))?; + + let note_id: NoteId = NoteId::from_raw( + Word::try_from(note_id) + .map_err(|err| js_error_with_context(err, "failed to parse output note id"))?, + ); + + let record = client + .get_output_note(note_id) + .await + .map_err(|e| js_error_with_context(e, "failed reading output note"))? + .ok_or_else(|| from_str_err("No output note found for the given id"))?; + + let scan_after_block_num = record.expected_height(); + let native_note: NativeNote = record.try_into().map_err(|e| { + js_error_with_context(e, "output note has no details to relay (recipient unknown)") + })?; + + client + .send_private_note_with_block_hint(native_note, &address.into(), scan_after_block_num) + .await + .map_err(|e| js_error_with_context(e, "failed sending private output note"))?; + + Ok(()) + } + /// Fetch private notes from the note transport layer /// /// Uses an internal pagination mechanism to avoid fetching duplicate notes: only notes past diff --git a/crates/web-client/test/note_transport.node.test.ts b/crates/web-client/test/note_transport.node.test.ts new file mode 100644 index 00000000..1cfc8600 --- /dev/null +++ b/crates/web-client/test/note_transport.node.test.ts @@ -0,0 +1,246 @@ +// @ts-nocheck +import { test, expect } from "./test-setup"; + +// Regression guard for the block-hint overshoot bug (web-sdk#262): a sender that +// relays a private note AFTER syncing past the note's on-chain commitment must +// still deliver it. `sendPrivateOutputNote` derives the recipient's scan-start +// block from the note's stored expected_height (the submission tip, at or below the +// commitment); a sync-height hint would sit ABOVE the commitment once the sender +// advanced past it, and the recipient — which scans FORWARD from the hint — would +// silently never bind the note. +// +// This is a CROSS-client test. A private note's details are not on-chain, so the +// recipient can only obtain them through the transport layer (never by auto-import +// from the chain), and a same-client mock chain auto-imports the committed note for +// a tracked recipient, bypassing the transport entirely. So the recipient lives on a +// second client that shares only the sender's post-relay mock chain + note-transport +// node — exactly the sender→recipient split the bug affects. +// +// It lives in a `.node.test.ts` file (node-only) on purpose. The behavior under test +// is platform-independent Rust, so the napi client exercises the identical code path. +// The browser mock harness serializes the whole mock chain through its worker on every +// delegated op (see the note in test-setup's setupBrowserPage), and driving two full +// mock clients plus chain/transport serialization through that path hangs — a harness +// limitation unrelated to the SDK behavior this guards. +test("private-note recipient still receives after the sender syncs past the note's commitment", async ({ + run, +}) => { + const result = await run(async ({ sdk, helpers }) => { + // ── Client A (sender): create recipient + faucet on the sender's store ── + const sender = await helpers.createFreshMockClient(); + if (!sender) return { skip: true }; + + const recipientWallet = await sender.newWallet( + sdk.AccountStorageMode.private(), + sdk.AuthScheme.AuthRpoFalcon512 + ); + + const faucet = await sender.newFaucet( + sdk.AccountStorageMode.private(), + false, + "DAG", + "DAG", + 8, + sdk.u64(10000000), + sdk.AuthScheme.AuthRpoFalcon512 + ); + + // ── Mint a PRIVATE note to the recipient and commit it (block C) ── + const mintRequest = await sender.newMintTransactionRequest( + recipientWallet.id(), + faucet.id(), + sdk.NoteType.Private, + sdk.u64(1000) + ); + const mintTxId = await sender.submitNewTransaction( + faucet.id(), + mintRequest + ); + await sender.proveBlock(); + await sender.syncState(); + + const [mintTxRecord] = await sender.getTransactions( + sdk.TransactionFilter.ids([mintTxId]) + ); + const relayedNoteId = mintTxRecord.outputNotes().notes()[0].id().toString(); + // The note commits at this block; the sender advances past it before relaying. + const heightAtCommit = await sender.getSyncHeight(); + + // ── Advance the sender PAST the commitment, THEN relay ── + // This is the bug's trigger: the sender's sync height is now above the note's + // commitment block, so a naive sync-height hint would overshoot it. + for (let i = 0; i < 3; i++) { + await sender.proveBlock(); + await sender.syncState(); + } + const heightAtRelay = await sender.getSyncHeight(); + const recipientAddress = sdk.Address.fromAccountId( + recipientWallet.id(), + "BasicWallet" + ); + // Relay via the convenience method: it derives the recipient's scan-start block from the + // output note's expected_height (the submission tip, at or below the commitment), NOT the + // sender's now-advanced sync height — so delivery survives the sync advance. + await sender.sendPrivateOutputNote(relayedNoteId, recipientAddress); + + // Snapshot the sender's chain + transport (post-relay) and export the + // recipient account so a fresh client can track and receive. + const serializedChain = await sender.serializeMockChain(); + const serializedTransport = await sender.serializeMockNoteTransportNode(); + const recipientAccountBytes = ( + await sender.exportAccountFile(recipientWallet.id()) + ).serialize(); + + // ── Client B (recipient): separate store, sharing A's chain + transport ── + const recipient = await helpers.createFreshMockClient( + serializedChain, + serializedTransport + ); + if (!recipient) return { skip: true }; + + // Sync the recipient to the shared chain tip BEFORE it starts tracking the + // recipient account. This is the bug's precondition: the recipient's sync + // height is already past the note's commitment block, so its own sync never + // re-scans that block for its tag — it must rely entirely on the transport's + // block hint to locate the commitment. + await recipient.syncState(); + await recipient.importAccountFile( + sdk.AccountFile.deserialize(recipientAccountBytes) + ); + + // The delivery path for a private note is the transport layer: fetch the + // details, then scan forward from the block hint for the on-chain commitment. + await recipient.fetchPrivateNotes(); + await recipient.syncState(); + + // The discriminator is COMMITTED, not All: the transport always imports the + // details (so an uncommitted "expected" record appears under All in both the + // fixed and buggy cases). Only a hint at or below the commitment lets the + // recipient locate the on-chain commitment and bind the note as committed. + const committed = await recipient.getInputNotes( + new sdk.NoteFilter(sdk.NoteFilterTypes.Committed) + ); + + return { + skip: false, + relayedNoteId, + heightAtCommit, + heightAtRelay, + committedCount: committed.length, + committedNoteId: committed[0] ? committed[0].id().toString() : null, + }; + }); + + if (result.skip) return; + + // Precondition: the sender relayed only after syncing past the note's + // commitment block, so a naive sync-height hint would overshoot it. + expect(result.heightAtRelay).toBeGreaterThan(result.heightAtCommit); + + // The recipient — already synced past the commitment before it began tracking + // the account — must still bind the note via the transport's commitment-block + // hint. With the pre-fix sync-height hint the forward scan starts above the + // commitment, never reaches it, and the note stays uncommitted (count 0). + expect(result.committedCount).toBe(1); + expect(result.committedNoteId).toBe(result.relayedNoteId); +}); + +// Companion guard for the agnostic low-level `sendPrivateNote(note, address, +// scanAfterBlockNum)`: the explicit hint is honoured, so relaying with a hint ABOVE +// the note's commitment block makes the recipient scan forward from above the +// commitment and never bind the note. This is the failure mode the block hint exists +// to prevent, and it's what a caller would hit if they passed the client's (advanced) +// sync height — exactly why the API forces the caller to choose the block. +test("agnostic sendPrivateNote does NOT deliver when the explicit hint overshoots the commitment", async ({ + run, +}) => { + const result = await run(async ({ sdk, helpers }) => { + const sender = await helpers.createFreshMockClient(); + if (!sender) return { skip: true }; + + const recipientWallet = await sender.newWallet( + sdk.AccountStorageMode.private(), + sdk.AuthScheme.AuthRpoFalcon512 + ); + const faucet = await sender.newFaucet( + sdk.AccountStorageMode.private(), + false, + "DAG", + "DAG", + 8, + sdk.u64(10000000), + sdk.AuthScheme.AuthRpoFalcon512 + ); + + // Mint a PRIVATE note to the recipient and commit it (block C). + const mintRequest = await sender.newMintTransactionRequest( + recipientWallet.id(), + faucet.id(), + sdk.NoteType.Private, + sdk.u64(1000) + ); + const mintTxId = await sender.submitNewTransaction( + faucet.id(), + mintRequest + ); + await sender.proveBlock(); + await sender.syncState(); + + const [mintTxRecord] = await sender.getTransactions( + sdk.TransactionFilter.ids([mintTxId]) + ); + const relayedNoteId = mintTxRecord.outputNotes().notes()[0].id().toString(); + const heightAtCommit = await sender.getSyncHeight(); + const note = (await sender.getInputNote(relayedNoteId)).toNote(); + + // Advance PAST the commitment, then relay with a deliberately-too-high hint + // (the sender's now-advanced sync height) via the agnostic low-level method. + for (let i = 0; i < 3; i++) { + await sender.proveBlock(); + await sender.syncState(); + } + const heightAtRelay = await sender.getSyncHeight(); + const recipientAddress = sdk.Address.fromAccountId( + recipientWallet.id(), + "BasicWallet" + ); + await sender.sendPrivateNote(note, recipientAddress, heightAtRelay); + + const serializedChain = await sender.serializeMockChain(); + const serializedTransport = await sender.serializeMockNoteTransportNode(); + const recipientAccountBytes = ( + await sender.exportAccountFile(recipientWallet.id()) + ).serialize(); + + const recipient = await helpers.createFreshMockClient( + serializedChain, + serializedTransport + ); + if (!recipient) return { skip: true }; + + await recipient.syncState(); + await recipient.importAccountFile( + sdk.AccountFile.deserialize(recipientAccountBytes) + ); + await recipient.fetchPrivateNotes(); + await recipient.syncState(); + + const committed = await recipient.getInputNotes( + new sdk.NoteFilter(sdk.NoteFilterTypes.Committed) + ); + + return { + skip: false, + heightAtCommit, + heightAtRelay, + committedCount: committed.length, + }; + }); + + if (result.skip) return; + + // The hint overshoots the commitment... + expect(result.heightAtRelay).toBeGreaterThan(result.heightAtCommit); + // ...so the recipient never binds the note: the explicit hint is honoured. + expect(result.committedCount).toBe(0); +}); diff --git a/crates/web-client/test/note_transport.test.ts b/crates/web-client/test/note_transport.test.ts index e64c7345..8243bd27 100644 --- a/crates/web-client/test/note_transport.test.ts +++ b/crates/web-client/test/note_transport.test.ts @@ -55,8 +55,10 @@ test("transport basic", async ({ run }) => { ); const notesBeforeSending = notes.length; - // Send note - await mockClient.sendPrivateNote(note, recipientAddress); + // Send note. The note is uncommitted here (never minted on-chain), so this + // same-client relay+fetch roundtrip just needs any valid scan-start block; 0 + // (genesis) always covers it. + await mockClient.sendPrivateNote(note, recipientAddress, 0); // 1 note stored await mockClient.fetchPrivateNotes(); diff --git a/crates/web-client/test/test-helpers.ts b/crates/web-client/test/test-helpers.ts index aefcbd81..a7214095 100644 --- a/crates/web-client/test/test-helpers.ts +++ b/crates/web-client/test/test-helpers.ts @@ -639,7 +639,11 @@ export function parseNetworkId(sdk: any, networkId: string): any { * Creates a fresh mock client (separate from the test fixture's client). * Useful for tests that need multiple independent clients. */ -export async function createFreshMockClient(sdk: any): Promise { +export async function createFreshMockClient( + sdk: any, + serializedMockChain?: any, + serializedNoteTransport?: any +): Promise { let rawSdk; try { rawSdk = loadNodeSdk(); @@ -653,8 +657,8 @@ export async function createFreshMockClient(sdk: any): Promise { path.join(dir, "store.db"), path.join(dir, "keystore"), null, - null, - null + serializedMockChain ?? null, + serializedNoteTransport ?? null ); return wrapNodeClient(rawClient, rawSdk); diff --git a/crates/web-client/test/test-setup.ts b/crates/web-client/test/test-setup.ts index 3e5772ea..2579648d 100644 --- a/crates/web-client/test/test-setup.ts +++ b/crates/web-client/test/test-setup.ts @@ -1164,7 +1164,15 @@ async function createNodeRunHelpers(client: any, sdk: any): Promise { waitForTransaction: (txId: string, maxWait?: number, interval?: number) => waitForTransaction(client, sdk, txId, maxWait, interval), parseNetworkId: (networkId: string) => h.parseNetworkId(sdk, networkId), - createFreshMockClient: () => h.createFreshMockClient(sdk), + createFreshMockClient: ( + serializedMockChain?: any, + serializedNoteTransport?: any + ) => + h.createFreshMockClient( + sdk, + serializedMockChain, + serializedNoteTransport + ), createIntegrationClient: () => h.createIntegrationClient(), createMidenMockClient: async () => { const MidenClient = await h.createMidenClient(sdk); diff --git a/packages/react-sdk/README.md b/packages/react-sdk/README.md index e377f843..28b491fe 100644 --- a/packages/react-sdk/README.md +++ b/packages/react-sdk/README.md @@ -742,7 +742,7 @@ function SendForm() { Create multiple P2ID output notes in a single transaction. This is ideal for batched payouts or airdrops; with `noteType: 'private'`, the hook also delivers -each note to recipients via `sendPrivateNote`. +each note to recipients via `sendPrivateOutputNote`. It builds the request and executes the full pipeline in one go. That means fewer chances to handle batching incorrectly or forget private note delivery. diff --git a/packages/react-sdk/src/__tests__/hooks/useMultiSend.test.tsx b/packages/react-sdk/src/__tests__/hooks/useMultiSend.test.tsx index 2f9765f9..2c569351 100644 --- a/packages/react-sdk/src/__tests__/hooks/useMultiSend.test.tsx +++ b/packages/react-sdk/src/__tests__/hooks/useMultiSend.test.tsx @@ -122,7 +122,7 @@ describe("useMultiSend", () => { expect(result.current.result).toEqual({ transactionId: "0xmultisend" }); expect(result.current.stage).toBe("complete"); expect(mockSync).toHaveBeenCalled(); - expect(mockClient.sendPrivateNote).toHaveBeenCalledTimes(2); + expect(mockClient.sendPrivateOutputNote).toHaveBeenCalledTimes(2); const createP2IDNoteMock = ( Note as unknown as { createP2IDNote: ReturnType } @@ -175,7 +175,7 @@ describe("useMultiSend", () => { NoteType.Public, expect.anything() ); - expect(mockClient.sendPrivateNote).not.toHaveBeenCalled(); + expect(mockClient.sendPrivateOutputNote).not.toHaveBeenCalled(); }); it("should reject concurrent sends with SEND_BUSY", async () => { diff --git a/packages/react-sdk/src/__tests__/hooks/useSend.test.tsx b/packages/react-sdk/src/__tests__/hooks/useSend.test.tsx index 902ee6d9..82cb7e02 100644 --- a/packages/react-sdk/src/__tests__/hooks/useSend.test.tsx +++ b/packages/react-sdk/src/__tests__/hooks/useSend.test.tsx @@ -923,7 +923,7 @@ describe("useSend", () => { }); }); - expect(mockClient.sendPrivateNote).toHaveBeenCalledTimes(1); + expect(mockClient.sendPrivateOutputNote).toHaveBeenCalledTimes(1); expect(result.current.stage).toBe("complete"); }); }); diff --git a/packages/react-sdk/src/__tests__/hooks/useTransaction.test.tsx b/packages/react-sdk/src/__tests__/hooks/useTransaction.test.tsx index 5d5573e4..429068b3 100644 --- a/packages/react-sdk/src/__tests__/hooks/useTransaction.test.tsx +++ b/packages/react-sdk/src/__tests__/hooks/useTransaction.test.tsx @@ -351,7 +351,7 @@ describe("useTransaction", () => { submitProvenTransaction: vi.fn().mockResolvedValue(100), applyTransaction: vi.fn().mockResolvedValue({}), getTransactions: vi.fn().mockResolvedValue([record]), - sendPrivateNote: vi.fn().mockResolvedValue(undefined), + sendPrivateOutputNote: vi.fn().mockResolvedValue(undefined), }); mockUseMiden.mockReturnValue({ @@ -376,7 +376,7 @@ describe("useTransaction", () => { expect(mockClient.proveTransaction).toHaveBeenCalled(); expect(mockClient.submitProvenTransaction).toHaveBeenCalled(); expect(mockClient.applyTransaction).toHaveBeenCalled(); - expect(mockClient.sendPrivateNote).toHaveBeenCalledTimes(1); + expect(mockClient.sendPrivateOutputNote).toHaveBeenCalledTimes(1); expect(result.current.stage).toBe("complete"); expect(mockSync).toHaveBeenCalled(); }); @@ -411,7 +411,7 @@ describe("useTransaction", () => { submitProvenTransaction: vi.fn().mockResolvedValue(100), applyTransaction: vi.fn().mockResolvedValue({}), getTransactions: vi.fn().mockResolvedValue([record]), - sendPrivateNote: vi.fn().mockResolvedValue(undefined), + sendPrivateOutputNote: vi.fn().mockResolvedValue(undefined), }); mockUseMiden.mockReturnValue({ @@ -430,7 +430,7 @@ describe("useTransaction", () => { }); }); - expect(mockClient.sendPrivateNote).not.toHaveBeenCalled(); + expect(mockClient.sendPrivateOutputNote).not.toHaveBeenCalled(); }); it("should handle errors in pipeline", async () => { diff --git a/packages/react-sdk/src/__tests__/mocks/miden-sdk.ts b/packages/react-sdk/src/__tests__/mocks/miden-sdk.ts index df6dae14..4f52173a 100644 --- a/packages/react-sdk/src/__tests__/mocks/miden-sdk.ts +++ b/packages/react-sdk/src/__tests__/mocks/miden-sdk.ts @@ -301,6 +301,7 @@ export const createMockWebClient = ( } return undefined; }), + sendPrivateOutputNote: vi.fn().mockResolvedValue(undefined), importAccountFile: vi.fn().mockResolvedValue("Imported account"), importAccountById: vi.fn().mockResolvedValue(undefined), importPublicAccountFromSeed: vi.fn().mockResolvedValue(createMockAccount()), @@ -361,6 +362,7 @@ type MockWebClientType = { submitProvenTransaction: ReturnType; applyTransaction: ReturnType; sendPrivateNote: ReturnType; + sendPrivateOutputNote: ReturnType; importAccountFile: ReturnType; importAccountById: ReturnType; importPublicAccountFromSeed: ReturnType; diff --git a/packages/react-sdk/src/hooks/useMultiSend.ts b/packages/react-sdk/src/hooks/useMultiSend.ts index de29b3db..b25e32b4 100644 --- a/packages/react-sdk/src/hooks/useMultiSend.ts +++ b/packages/react-sdk/src/hooks/useMultiSend.ts @@ -141,7 +141,7 @@ export function useMultiSend(): UseMultiSendResult { // NoteArray constructor consumes its elements via Vec; use // push(¬e) so each output.note handle stays valid for the - // sendPrivateNote loop below. + // sendPrivateOutputNote loop below. const ownOutputs = new NoteArray(); for (const o of outputs) { ownOutputs.push(o.note); @@ -186,8 +186,8 @@ export function useMultiSend(): UseMultiSendResult { for (const output of outputs) { if (output.noteType === NoteType.Private) { - await client.sendPrivateNote( - output.note, + await client.sendPrivateOutputNote( + output.note.id().toString(), output.recipientAddress ); } diff --git a/packages/react-sdk/src/hooks/useSend.ts b/packages/react-sdk/src/hooks/useSend.ts index f7fd1669..e0af4df8 100644 --- a/packages/react-sdk/src/hooks/useSend.ts +++ b/packages/react-sdk/src/hooks/useSend.ts @@ -283,7 +283,10 @@ export function useSend(): UseSendResult { const recipientAccountId = parseAccountId(options.to); const recipientAddress = parseAddress(options.to, recipientAccountId); await runExclusiveSafe(() => - client.sendPrivateNote(fullNote!, recipientAddress) + client.sendPrivateOutputNote( + fullNote!.id().toString(), + recipientAddress + ) ); } diff --git a/packages/react-sdk/src/hooks/useTransaction.ts b/packages/react-sdk/src/hooks/useTransaction.ts index be699890..e22d7960 100644 --- a/packages/react-sdk/src/hooks/useTransaction.ts +++ b/packages/react-sdk/src/hooks/useTransaction.ts @@ -149,8 +149,12 @@ export function useTransaction(): UseTransactionResult { const targetAddress = parseAddress(options.privateNoteTarget); const fullNotes = extractFullNotes(txResult); for (const note of fullNotes) { + // Relay via the output-note convenience: it derives the recipient's + // scan-start block from the note's expected height, so delivery is + // correct even though we relay after waiting for the commit (which has + // advanced this client's sync height past the note's commitment block). await runExclusiveSafe(() => - client.sendPrivateNote(note, targetAddress) + client.sendPrivateOutputNote(note.id().toString(), targetAddress) ); } }