diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b4048216..86806cb9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,12 +20,12 @@ env: # cryptic 'invalid wire type' decode errors on the client side (e.g. Digest # fields encoded as bytes vs fixed64), or 'accept header validation failed' # if the protocol version differs. - # Pinned to the head of the rust-sdk `encrypted-tx-inputs` branch, matching - # the miden-client git patch in Cargo.toml: its Cargo.lock pins the node rev - # that serves `GetTransactionEncryptionKey`, which the patched client calls - # on every first submission. A commit sha (not the branch name) keeps cache - # keys stable. Re-pin to the release tag once the next alpha ships. - MIDEN_CLIENT_REF: cf510e39fafa647aea8fb863ba779e92a8f7d9c2 + # Pinned to the head of the rust-sdk `note_filter_script_root` branch, matching + # the miden-client git dependency in Cargo.toml: its Cargo.lock pins the node + # rev that serves `GetTransactionEncryptionKey`, which the client calls on + # every first submission. A commit sha (not the branch name) keeps cache keys + # stable. Re-pin to the release tag once the next alpha ships. + MIDEN_CLIENT_REF: e70d3df8651872a5095b55bc78774aa5ab24a1bc jobs: # Pre-flight: detect whether any non-docs files changed. See build.yml's diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b9d4f66..a9ddec65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,11 @@ * [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. -* [CHANGE][web] `miden-client` and `miden-client-sqlite-store` are pinned to the rust-sdk `encrypted-tx-inputs` branch ([#2341](https://github.com/0xMiden/rust-sdk/pull/2341), `32dfba8d`), ahead of the `0.16.0-alpha.1` release, pending the next alpha. Inherited upstream changes include encrypted transaction submission (see above), note-transport attachment support, a note-screener batch cache, faster historical-note retrieval, and single-account note screening: `notes.listAvailable({ account })` now screens the given account only, instead of screening every tracked account and discarding the rest, so its cost no longer grows with the number of tracked accounts. Protocol-layer versions are unchanged (`miden-protocol` / `miden-standards` / `miden-tx` at `0.16.0-alpha.4`). +* [CHANGE][web] `miden-client` and `miden-client-sqlite-store` are pinned to the rust-sdk `note_filter_script_root` branch ([#2335](https://github.com/0xMiden/rust-sdk/pull/2335), `e70d3df8`), which merges rust-sdk `next` (where encrypted transaction inputs landed, [#2341](https://github.com/0xMiden/rust-sdk/pull/2341)), ahead of the `0.16.0-alpha.1` release, pending the next alpha. Inherited upstream changes include encrypted transaction submission (see above), the store-level note script root filter (see below), note-transport attachment support, a note-screener batch cache, faster historical-note retrieval, and single-account note screening: `notes.listAvailable({ account })` now screens the given account only, instead of screening every tracked account and discarding the rest, so its cost no longer grows with the number of tracked accounts. Protocol-layer versions are unchanged (`miden-protocol` / `miden-standards` / `miden-tx` at `0.16.0-alpha.4`). + +### Enhancements + +* [FEATURE][web] `notes.list({ scriptRoots: [...] })` filters received notes by note script root, given as hex strings or `Word` instances (e.g. from `NoteScript.root()`). This narrows candidate notes at the store level, without loading and screening unrelated notes. `notes.listSent` returns an empty list for this query, since script roots are only tracked for received notes. ([#249](https://github.com/0xMiden/web-sdk/pull/249), client [#2335](https://github.com/0xMiden/rust-sdk/pull/2335)) ## 0.16.0-alpha.1 (2026-07-19) diff --git a/Cargo.lock b/Cargo.lock index 196ce5de..4c20d1bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1752,7 +1752,7 @@ dependencies = [ [[package]] name = "miden-client" version = "0.16.0-alpha.1" -source = "git+https://github.com/0xMiden/rust-sdk?branch=next#cf510e39fafa647aea8fb863ba779e92a8f7d9c2" +source = "git+https://github.com/0xMiden/rust-sdk?branch=note_filter_script_root#e70d3df8651872a5095b55bc78774aa5ab24a1bc" dependencies = [ "anyhow", "async-trait", @@ -1790,7 +1790,7 @@ dependencies = [ [[package]] name = "miden-client-sqlite-store" version = "0.16.0-alpha.1" -source = "git+https://github.com/0xMiden/rust-sdk?branch=next#cf510e39fafa647aea8fb863ba779e92a8f7d9c2" +source = "git+https://github.com/0xMiden/rust-sdk?branch=note_filter_script_root#e70d3df8651872a5095b55bc78774aa5ab24a1bc" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 3b8a3c95..d7716847 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,8 +42,11 @@ js-export-macro = { path = "crates/js-export-macro", version = "0.16.0-alpha.1" # Cargo.lock intentionally follows the rust-sdk v0.16.0-alpha.1 release graph, including the # Miden VM 0.25.3 family and wincode 0.5.5. Broad `cargo update` runs currently select newer # transitive versions; validate those against this alpha before accepting lockfile changes. -miden-client = { branch = "next", default-features = false, git = "https://github.com/0xMiden/rust-sdk" } -miden-client-sqlite-store = { branch = "next", default-features = false, git = "https://github.com/0xMiden/rust-sdk" } +# TEMPORARY: pinned to the note-filter-by-script-root branch (0xMiden/rust-sdk#2335), which +# carries the store-level note script root filter and merges rust-sdk `next`. Point these back +# at `next` once #2335 lands there, and at registry versions once the next alpha is published. +miden-client = { branch = "note_filter_script_root", default-features = false, git = "https://github.com/0xMiden/rust-sdk" } +miden-client-sqlite-store = { branch = "note_filter_script_root", default-features = false, git = "https://github.com/0xMiden/rust-sdk" } # External dependencies async-trait = { version = "0.1" } diff --git a/crates/idxdb-store/src/js/notes.js b/crates/idxdb-store/src/js/notes.js index d1f49312..8a374bd3 100644 --- a/crates/idxdb-store/src/js/notes.js +++ b/crates/idxdb-store/src/js/notes.js @@ -83,6 +83,19 @@ export async function getInputNotesFromDetailsCommitments(dbId, detailsCommitmen logWebStoreError(err, "Failed to get input notes from details commitments"); } } +export async function getInputNotesFromScriptRoots(dbId, scriptRoots) { + try { + const db = getDatabase(dbId); + let notes = await db.inputNotes + .where("scriptRoot") + .anyOf(scriptRoots) + .toArray(); + return await processInputNotes(dbId, notes); + } + catch (err) { + logWebStoreError(err, "Failed to get input notes from script roots"); + } +} export async function getOutputNotesFromDetailsCommitments(dbId, detailsCommitments) { try { const db = getDatabase(dbId); diff --git a/crates/idxdb-store/src/js/schema.js b/crates/idxdb-store/src/js/schema.js index 314f7a94..f29f33d6 100644 --- a/crates/idxdb-store/src/js/schema.js +++ b/crates/idxdb-store/src/js/schema.js @@ -81,7 +81,7 @@ const V1_STORES = { [Table.Addresses]: indexes("address", "id"), [Table.Transactions]: indexes("id", "statusVariant"), [Table.TransactionScripts]: indexes("scriptRoot"), - [Table.InputNotes]: indexes("detailsCommitment", "noteId", "nullifier", "stateDiscriminant", "[consumedBlockHeight+consumedTxOrder+noteId]"), + [Table.InputNotes]: indexes("detailsCommitment", "noteId", "nullifier", "scriptRoot", "stateDiscriminant", "[consumedBlockHeight+consumedTxOrder+noteId]"), [Table.OutputNotes]: indexes("detailsCommitment", "noteId", "recipientDigest", "stateDiscriminant", "nullifier"), [Table.NotesScripts]: indexes("scriptRoot"), [Table.BlockchainCheckpoint]: indexes("id"), diff --git a/crates/idxdb-store/src/note/js_bindings.rs b/crates/idxdb-store/src/note/js_bindings.rs index 9f372991..9e89af0b 100644 --- a/crates/idxdb-store/src/note/js_bindings.rs +++ b/crates/idxdb-store/src/note/js_bindings.rs @@ -29,6 +29,12 @@ extern "C" { details_commitments: Vec, ) -> js_sys::Promise; + #[wasm_bindgen(js_name = getInputNotesFromScriptRoots)] + pub fn idxdb_get_input_notes_from_script_roots( + db_id: &str, + script_roots: Vec, + ) -> js_sys::Promise; + #[wasm_bindgen(js_name = getOutputNotes)] pub fn idxdb_get_output_notes(db_id: &str, states: Vec) -> js_sys::Promise; diff --git a/crates/idxdb-store/src/note/mod.rs b/crates/idxdb-store/src/note/mod.rs index 484ea917..4586ad2f 100644 --- a/crates/idxdb-store/src/note/mod.rs +++ b/crates/idxdb-store/src/note/mod.rs @@ -3,7 +3,13 @@ use alloc::vec::Vec; use miden_client::Word; use miden_client::account::AccountId; -use miden_client::note::{BlockNumber, NoteDetailsCommitment, NoteScript, Nullifier}; +use miden_client::note::{ + BlockNumber, + NoteDetailsCommitment, + NoteScript, + NoteScriptRoot, + Nullifier, +}; use miden_client::store::{ InputNoteRecord, InputNoteState, @@ -26,6 +32,7 @@ use js_bindings::{ idxdb_get_input_notes_from_details_commitments, idxdb_get_input_notes_from_ids, idxdb_get_input_notes_from_nullifiers, + idxdb_get_input_notes_from_script_roots, idxdb_get_note_script, idxdb_get_output_notes, idxdb_get_output_notes_from_details_commitments, @@ -178,7 +185,8 @@ fn input_note_state_discriminants(filter: &NoteFilter) -> Option> { NoteFilter::List(_) | NoteFilter::Unique(_) | NoteFilter::Nullifiers(_) - | NoteFilter::DetailsCommitments(_) => None, + | NoteFilter::DetailsCommitments(_) + | NoteFilter::ScriptRoots(_) => None, } } @@ -223,6 +231,11 @@ impl NoteFilterExt for NoteFilter { commitments.iter().map(NoteDetailsCommitment::to_hex).collect(); idxdb_get_input_notes_from_details_commitments(db_id, commitments_as_str) }, + NoteFilter::ScriptRoots(script_roots) => { + let script_roots_as_str: Vec = + script_roots.iter().map(NoteScriptRoot::to_hex).collect(); + idxdb_get_input_notes_from_script_roots(db_id, script_roots_as_str) + }, } } @@ -255,7 +268,7 @@ impl NoteFilterExt for NoteFilter { idxdb_get_output_notes(db_id, states) }, - NoteFilter::Processing | NoteFilter::Unverified => { + NoteFilter::Processing | NoteFilter::ScriptRoots(_) | NoteFilter::Unverified => { Promise::resolve(&JsValue::from(Array::new())) }, NoteFilter::List(ids) => { diff --git a/crates/idxdb-store/src/ts/notes.test.ts b/crates/idxdb-store/src/ts/notes.test.ts index b75ee9e2..de412099 100644 --- a/crates/idxdb-store/src/ts/notes.test.ts +++ b/crates/idxdb-store/src/ts/notes.test.ts @@ -8,6 +8,7 @@ import { getInputNotes, getInputNotesFromIds, getInputNotesFromNullifiers, + getInputNotesFromScriptRoots, getOutputNotes, getOutputNotesFromIds, getOutputNotesFromNullifiers, @@ -538,6 +539,41 @@ describe("getInputNotesFromIds", () => { }); }); +// ================================================================================================ +// getInputNotesFromScriptRoots +// ================================================================================================ + +describe("getInputNotesFromScriptRoots", () => { + it("returns notes matching the given script roots", async () => { + const dbId = await openTestDb(); + await insertNote(dbId, "root-note-1", { scriptRoot: "0xroot1" }); + await insertNote(dbId, "root-note-2", { scriptRoot: "0xroot1" }); + await insertNote(dbId, "root-note-3", { scriptRoot: "0xroot2" }); + + const result = await getInputNotesFromScriptRoots(dbId, ["0xroot1"]); + expect(result).toHaveLength(2); + // createdAt holds the noteId (see insertNote) + expect(result?.map((note) => note.createdAt).sort()).toEqual([ + "root-note-1", + "root-note-2", + ]); + + const combined = await getInputNotesFromScriptRoots(dbId, [ + "0xroot1", + "0xroot2", + ]); + expect(combined).toHaveLength(3); + }); + + it("returns empty array for unmatched script roots", async () => { + const dbId = await openTestDb(); + await insertNote(dbId, "root-note-1", { scriptRoot: "0xroot1" }); + + const result = await getInputNotesFromScriptRoots(dbId, ["0xother"]); + expect(result).toEqual([]); + }); +}); + // ================================================================================================ // getInputNotesFromNullifiers // ================================================================================================ diff --git a/crates/idxdb-store/src/ts/notes.ts b/crates/idxdb-store/src/ts/notes.ts index 5826a08a..da044369 100644 --- a/crates/idxdb-store/src/ts/notes.ts +++ b/crates/idxdb-store/src/ts/notes.ts @@ -98,6 +98,22 @@ export async function getInputNotesFromDetailsCommitments( } } +export async function getInputNotesFromScriptRoots( + dbId: string, + scriptRoots: string[] +) { + try { + const db = getDatabase(dbId); + let notes = await db.inputNotes + .where("scriptRoot") + .anyOf(scriptRoots) + .toArray(); + return await processInputNotes(dbId, notes); + } catch (err) { + logWebStoreError(err, "Failed to get input notes from script roots"); + } +} + export async function getOutputNotesFromDetailsCommitments( dbId: string, detailsCommitments: string[] diff --git a/crates/idxdb-store/src/ts/schema.ts b/crates/idxdb-store/src/ts/schema.ts index fe406a3d..b617319e 100644 --- a/crates/idxdb-store/src/ts/schema.ts +++ b/crates/idxdb-store/src/ts/schema.ts @@ -314,6 +314,7 @@ const V1_STORES: Record = { "detailsCommitment", "noteId", "nullifier", + "scriptRoot", "stateDiscriminant", "[consumedBlockHeight+consumedTxOrder+noteId]" ), diff --git a/crates/web-client/js/__tests__/resources/notes.test.js b/crates/web-client/js/__tests__/resources/notes.test.js index 278c178b..1a3028b6 100644 --- a/crates/web-client/js/__tests__/resources/notes.test.js +++ b/crates/web-client/js/__tests__/resources/notes.test.js @@ -10,6 +10,7 @@ function makeWasm(overrides = {}) { Processing: "Processing", Unverified: "Unverified", List: "List", + ScriptRoots: "ScriptRoots", }; const filterInstance = { type: "filter" }; return { @@ -18,6 +19,9 @@ function makeWasm(overrides = {}) { NoteId: { fromHex: vi.fn((hex) => ({ hex })), }, + Word: { + fromHex: vi.fn((hex) => ({ hex })), + }, NoteExportFormat: { Full: "Full" }, AccountId: { fromHex: vi.fn((hex) => ({ hex })), @@ -125,6 +129,19 @@ describe("NotesResource", () => { expect(wasm.NoteFilter).toHaveBeenCalledWith("List", expect.any(Array)); }); + it("builds NoteFilter with script roots when query.scriptRoots provided", async () => { + inner.getInputNotes.mockResolvedValue([]); + const resource = makeResource(); + const wordRoot = { word: true }; + await resource.list({ scriptRoots: ["0xabc", wordRoot] }); + expect(wasm.Word.fromHex).toHaveBeenCalledTimes(1); + expect(wasm.Word.fromHex).toHaveBeenCalledWith("0xabc"); + expect(wasm.NoteFilter).toHaveBeenCalledWith("ScriptRoots", undefined, [ + { hex: "0xabc" }, + wordRoot, + ]); + }); + it("falls back to All when empty query object", async () => { inner.getInputNotes.mockResolvedValue([]); const resource = makeResource(); diff --git a/crates/web-client/js/resources/notes.js b/crates/web-client/js/resources/notes.js index db7b4a79..d7caf2ff 100644 --- a/crates/web-client/js/resources/notes.js +++ b/crates/web-client/js/resources/notes.js @@ -101,6 +101,17 @@ function buildNoteFilter(query, wasm) { return new wasm.NoteFilter(wasm.NoteFilterTypes.List, noteIds); } + if (query.scriptRoots) { + const scriptRoots = query.scriptRoots.map((root) => + typeof root === "string" ? wasm.Word.fromHex(root) : root + ); + return new wasm.NoteFilter( + wasm.NoteFilterTypes.ScriptRoots, + undefined, + scriptRoots + ); + } + if (query.status) { const statusMap = { consumed: wasm.NoteFilterTypes.Consumed, diff --git a/crates/web-client/js/types/api-types.d.ts b/crates/web-client/js/types/api-types.d.ts index 9f5823d1..f3b77544 100644 --- a/crates/web-client/js/types/api-types.d.ts +++ b/crates/web-client/js/types/api-types.d.ts @@ -566,7 +566,14 @@ export type NoteQuery = | "processing" | "unverified"; } - | { ids: (string | NoteId)[] }; + | { ids: (string | NoteId)[] } + /** + * Filter received notes by note script root, given as hex strings or Word + * instances (e.g. from `NoteScript.root()`). Notes match regardless of their + * state. Only supported by `notes.list`; `notes.listSent` returns an empty + * list for this query. + */ + | { scriptRoots: (string | Word)[] }; /** Options for standalone note creation utilities. */ export interface NoteOptions { @@ -817,9 +824,10 @@ export interface TransactionsResource { export interface NotesResource { /** - * List received (input) notes, optionally filtered by status or IDs. + * List received (input) notes, optionally filtered by status, IDs, or note + * script roots. * - * @param query - Optional filter by note status or note IDs. + * @param query - Optional filter by note status, note IDs, or script roots. */ list(query?: NoteQuery): Promise; /** @@ -830,7 +838,9 @@ export interface NotesResource { get(noteId: NoteInput): Promise; /** - * List sent (output) notes, optionally filtered by status or IDs. + * List sent (output) notes, optionally filtered by status or IDs. A script + * root query returns an empty list, since script roots are only tracked for + * received notes. * * @param query - Optional filter by note status or note IDs. */ diff --git a/crates/web-client/src/models/note_filter.rs b/crates/web-client/src/models/note_filter.rs index 5d6bebda..5c199e55 100644 --- a/crates/web-client/src/models/note_filter.rs +++ b/crates/web-client/src/models/note_filter.rs @@ -1,7 +1,9 @@ use js_export_macro::js_export; +use miden_client::note::NoteScriptRoot; use miden_client::store::NoteFilter as NativeNoteFilter; use super::note_id::NoteId; +use super::word::Word; // TODO: Add nullifier support @@ -11,14 +13,19 @@ use super::note_id::NoteId; pub struct NoteFilter { note_type: NoteFilterTypes, note_ids: Option>, + script_roots: Option>, } #[js_export] impl NoteFilter { - /// Creates a new filter for the given type and optional note IDs. + /// Creates a new filter for the given type and optional note IDs or script roots. #[js_export(constructor)] - pub fn new(note_type: NoteFilterTypes, note_ids: Option>) -> NoteFilter { - NoteFilter { note_type, note_ids } + pub fn new( + note_type: NoteFilterTypes, + note_ids: Option>, + script_roots: Option>, + ) -> NoteFilter { + NoteFilter { note_type, note_ids, script_roots } } } @@ -34,6 +41,7 @@ pub enum NoteFilterTypes { Unique, Nullifiers, Unverified, + ScriptRoots, } // CONVERSIONS @@ -41,28 +49,7 @@ pub enum NoteFilterTypes { impl From for NativeNoteFilter { fn from(filter: NoteFilter) -> Self { - match filter.note_type { - NoteFilterTypes::All => NativeNoteFilter::All, - NoteFilterTypes::Consumed => NativeNoteFilter::Consumed, - NoteFilterTypes::Committed => NativeNoteFilter::Committed, - NoteFilterTypes::Expected => NativeNoteFilter::Expected, - NoteFilterTypes::Processing => NativeNoteFilter::Processing, - NoteFilterTypes::List => { - let note_ids = - filter.note_ids.unwrap_or_else(|| panic!("Note IDs required for List filter")); - NativeNoteFilter::List(note_ids.iter().map(Into::into).collect()) - }, - NoteFilterTypes::Unique => { - let note_ids = - filter.note_ids.unwrap_or_else(|| panic!("Note ID required for Unique filter")); - - assert!(note_ids.len() == 1, "Only one Note ID can be provided"); - - NativeNoteFilter::Unique(note_ids.first().unwrap().into()) - }, - NoteFilterTypes::Nullifiers => NativeNoteFilter::Nullifiers(vec![]), - NoteFilterTypes::Unverified => NativeNoteFilter::Unverified, - } + (&filter).into() } } @@ -93,6 +80,19 @@ impl From<&NoteFilter> for NativeNoteFilter { }, NoteFilterTypes::Nullifiers => NativeNoteFilter::Nullifiers(vec![]), NoteFilterTypes::Unverified => NativeNoteFilter::Unverified, + NoteFilterTypes::ScriptRoots => { + let script_roots = filter + .script_roots + .clone() + .unwrap_or_else(|| panic!("Script roots required for ScriptRoots filter")); + + NativeNoteFilter::ScriptRoots( + script_roots + .iter() + .map(|script_root| NoteScriptRoot::from_raw(script_root.into())) + .collect(), + ) + }, } } }