Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
65 changes: 59 additions & 6 deletions crates/web-client/js/__tests__/resources/notes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ function makeInner() {
exportNoteFile: vi.fn(),
fetchPrivateNotes: vi.fn(),
sendPrivateNote: vi.fn(),
sendPrivateOutputNote: vi.fn(),
};
}

Expand Down Expand Up @@ -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
);
});

Expand All @@ -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");
});

Expand All @@ -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");
});
Expand Down
1 change: 1 addition & 0 deletions crates/web-client/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ const WRITE_METHODS = new Set([
"removeTag",
"removeSetting",
"sendPrivateNote",
"sendPrivateOutputNote",
"setSetting",
"submitNewTransactionBatch",
"submitProvenTransaction",
Expand Down
22 changes: 21 additions & 1 deletion crates/web-client/js/resources/notes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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);
}
}

Expand Down
39 changes: 37 additions & 2 deletions crates/web-client/js/types/api-types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -1227,11 +1244,29 @@ export interface NotesResource {
*/
fetchPrivate(): Promise<void>;
/**
* 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<void>;
/**
* 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<void>;
}

// ════════════════════════════════════════════════════════════════
Expand Down
71 changes: 63 additions & 8 deletions crates/web-client/src/note_transport.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading
Loading