From b7e45324355219e2a651eae5371142b7e219bcdd Mon Sep 17 00:00:00 2001 From: Juan Munoz Date: Thu, 28 May 2026 17:28:17 -0300 Subject: [PATCH 1/6] feat: add multi-account batch tx --- CHANGELOG.md | 2 +- crates/idxdb-store/src/account/mod.rs | 5 +- crates/idxdb-store/src/sync/mod.rs | 28 ++ crates/idxdb-store/src/transaction/mod.rs | 29 +- crates/web-client/README.md | 23 +- .../__tests__/resources/transactions.test.js | 142 +++++-- crates/web-client/js/node-index.js | 3 + .../web-client/js/resources/transactions.js | 89 ++--- crates/web-client/js/types/api-types.d.ts | 40 +- crates/web-client/playwright.config.ts | 1 + crates/web-client/src/models/batch_item.rs | 40 ++ crates/web-client/src/models/mod.rs | 1 + crates/web-client/src/new_transactions.rs | 42 +- crates/web-client/test/batch.browser.test.ts | 372 +++++++++++++++++- crates/web-client/test/global.test.d.ts | 2 + .../src/web-client/library/transactions.md | 42 +- packages/react-sdk/CLAUDE.md | 21 + packages/react-sdk/README.md | 56 +++ .../src/__tests__/hooks/useBatch.test.tsx | 268 +++++++++++++ .../src/__tests__/mocks/miden-sdk.ts | 2 + packages/react-sdk/src/hooks/useBatch.ts | 140 +++++++ packages/react-sdk/src/index.ts | 1 + packages/react-sdk/src/types/index.ts | 20 + 23 files changed, 1177 insertions(+), 192 deletions(-) create mode 100644 crates/web-client/src/models/batch_item.rs create mode 100644 packages/react-sdk/src/__tests__/hooks/useBatch.test.tsx create mode 100644 packages/react-sdk/src/hooks/useBatch.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e0259c8..67f8f407 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ ### Enhancements * [FEATURE][web,react] AggLayer bridge-out (B2AGG) note support. `client.transactions.bridge({ account, bridgeAccount, token, amount, destinationNetwork, destinationAddress })` bridges a fungible asset out to another network — emitting a single public B2AGG (Bridge-to-AggLayer) note that the bridge account consumes, burning the asset so it can be claimed at the destination Ethereum address on the AggLayer-assigned `destinationNetwork`. The lower-level builders are also exposed: `Note.createB2AggNote(sender, bridgeAccount, assets, destinationNetwork, destinationAddress)` and `client.newB2AggTransactionRequest(...)`. A new `EthAddress` class carries the 20-byte destination address (`EthAddress.fromHex("0x…")` / `EthAddress.fromBytes(bytes)`, with `toHex()` / `toBytes()`). The `@miden-sdk/react` `useBridge()` hook wraps the build-and-submit flow: `bridge({ from, bridgeAccount, assetId, amount, destinationNetwork, destinationAddress })`. Builds on the `miden-agglayer` re-export already present in the bundled `miden-client` — no new dependency. (closes [#173](https://github.com/0xMiden/web-sdk/issues/173)) -* [FEATURE][web] Added `client.transactions.batch({ account, operations })` to `MidenClient` for atomic multi-tx batches against a single account. Operations are discriminated by `kind` (`"send" | "mint" | "consume" | "swap" | "execute" | "custom"`) and reuse the same options shape as their singular counterparts. Returns `{ blockNumber }`. Companion `submitBatch(account, requests, options?)` is the lower-level escape hatch for pre-built `TransactionRequest`s. Wraps the underlying WASM `submitNewTransactionBatch` so consumers don't have to call `.serialize()` themselves. ([web-sdk#31](https://github.com/0xMiden/web-sdk/pull/31), client [#2109](https://github.com/0xMiden/miden-client/pull/2109)) +* [FEATURE][web] Added `client.transactions.batch({ operations })` to `MidenClient` for atomic multi-tx batches across one or more local accounts. Each operation specifies its executing `account`; a batch may mix operations across any combination of tracked accounts, and a later transaction may consume a note produced by an earlier one (cross-account in-batch note flow supported). Operations are discriminated by `kind` (`"send" | "mint" | "consume" | "swap" | "execute" | "custom"`) and reuse the same options shape as their singular counterparts. Returns `{ blockNumber }`. Companion `submitBatch(items, options?)` takes an array of `{ account, request }` pairs and is the lower-level escape hatch for pre-built `TransactionRequest`s. Wraps the underlying WASM `submitNewTransactionBatch(items: BatchItem[])`, where each `BatchItem` is a `(AccountId, TransactionRequest)` pair built via `new BatchItem(accountId, request)`. ([web-sdk#31](https://github.com/0xMiden/web-sdk/pull/31), client [#2109](https://github.com/0xMiden/miden-client/pull/2109), [#2177](https://github.com/0xMiden/miden-client/pull/2177)) ## 0.15.3 (2026-06-25) diff --git a/crates/idxdb-store/src/account/mod.rs b/crates/idxdb-store/src/account/mod.rs index 37b74518..dc3db4a1 100644 --- a/crates/idxdb-store/src/account/mod.rs +++ b/crates/idxdb-store/src/account/mod.rs @@ -1,4 +1,4 @@ -use alloc::collections::BTreeMap; +use alloc::collections::{BTreeMap, BTreeSet}; use alloc::string::{String, ToString}; use alloc::vec::Vec; @@ -343,8 +343,7 @@ impl IdxdbStore { } }, AccountStorageFilter::SlotNames(names) => { - let wanted: alloc::collections::BTreeSet<&str> = - names.iter().map(StorageSlotName::as_str).collect(); + let wanted: BTreeSet<&str> = names.iter().map(StorageSlotName::as_str).collect(); account_storage_idxdb .into_iter() .filter(|s| wanted.contains(s.slot_name.as_str())) diff --git a/crates/idxdb-store/src/sync/mod.rs b/crates/idxdb-store/src/sync/mod.rs index c1ba2c29..d62b955d 100644 --- a/crates/idxdb-store/src/sync/mod.rs +++ b/crates/idxdb-store/src/sync/mod.rs @@ -9,6 +9,7 @@ use miden_client::sync::{ NoteTagRecord, NoteTagSource, PartialBlockchainUpdates, + PublicAccountDelta, PublicAccountUpdate, StateSyncUpdate, }; @@ -371,6 +372,33 @@ impl IdxdbStore { self.undo_account_states(account_commitments).await?; Ok(()) } + + /// Converts a `PublicAccountDelta` (raw incremental RPC payload) into a protocol-level + /// `AccountDelta` by replaying the carried updates against the locally-stored account state. + /// Mirrors `apply_public_account_delta` in `sqlite-store`'s sync path. + async fn public_delta_to_account_delta( + &self, + public_delta: &PublicAccountDelta, + ) -> Result { + let account_id = public_delta.id(); + let local_header = self + .get_account_header(account_id) + .await? + .map(|(header, _)| header) + .ok_or(StoreError::AccountDataNotFound(account_id))?; + let local_storage = self + .get_account_storage( + account_id, + AccountStorageFilter::SlotNames(public_delta.value_slot_names()), + ) + .await?; + let local_vault = self.get_account_vault(account_id).await?; + public_delta + .compute_account_delta(&local_header, &local_storage, &local_vault) + .map_err(|err| { + StoreError::DatabaseError(format!("failed to compute public account delta: {err}")) + }) + } } /// Encodes a [`NoteTagSource`] into the three optional hex-string columns the diff --git a/crates/idxdb-store/src/transaction/mod.rs b/crates/idxdb-store/src/transaction/mod.rs index 8eb84778..04cb58ad 100644 --- a/crates/idxdb-store/src/transaction/mod.rs +++ b/crates/idxdb-store/src/transaction/mod.rs @@ -326,8 +326,9 @@ impl IdxdbStore { // Simulates read-writes across batch transactions, since nothing is persisted to // IndexedDB until the single Dexie transaction at the end. - let mut vault_overlay: BTreeMap> = BTreeMap::new(); - let mut map_roots_overlay: BTreeMap = BTreeMap::new(); + let mut vault_overlay: BTreeMap<(AccountId, AssetVaultKey), Option> = + BTreeMap::new(); + let mut map_roots_overlay: BTreeMap<(AccountId, StorageSlotName), Word> = BTreeMap::new(); for update in &tx_updates { let (payload, account_id) = self @@ -377,8 +378,8 @@ impl IdxdbStore { async fn prepare_update_for_batch( &self, update: &TransactionStoreUpdate, - vault_overlay: &mut BTreeMap>, - map_roots_overlay: &mut BTreeMap, + vault_overlay: &mut BTreeMap<(AccountId, AssetVaultKey), Option>, + map_roots_overlay: &mut BTreeMap<(AccountId, StorageSlotName), Word>, ) -> Result<(BatchUpdatePayload, AccountId), StoreError> { let executed_tx = update.executed_transaction(); let delta = executed_tx.account_delta(); @@ -422,14 +423,14 @@ impl IdxdbStore { // Seed overlays with the new full account state so subsequent non-full-state // preparations in the same batch see post-this-tx values. - vault_overlay.clear(); + vault_overlay.retain(|(acc, _), _| *acc != account_id); for asset in account.vault().assets() { - vault_overlay.insert(asset.vault_key(), Some(asset)); + vault_overlay.insert((account_id, asset.vault_key()), Some(asset)); } - map_roots_overlay.clear(); + map_roots_overlay.retain(|(acc, _), _| *acc != account_id); for slot in account.storage().slots() { if let StorageSlotContent::Map(map) = slot.content() { - map_roots_overlay.insert(slot.name().clone(), map.root()); + map_roots_overlay.insert((account_id, slot.name().clone()), map.root()); } } @@ -474,7 +475,7 @@ impl IdxdbStore { let mut old_vault_assets: Vec = Vec::new(); let mut vault_keys_to_fetch: Vec = Vec::new(); for (vault_key, _) in delta.vault().fungible().iter() { - match vault_overlay.get(vault_key) { + match vault_overlay.get(&(account_id, *vault_key)) { Some(Some(asset)) => old_vault_assets.push(*asset), Some(None) => { /* key was removed earlier in the batch; treat as empty */ }, None => vault_keys_to_fetch.push(vault_key.to_string()), @@ -488,7 +489,7 @@ impl IdxdbStore { let mut old_map_roots: BTreeMap = BTreeMap::new(); let mut map_slot_names_to_fetch: Vec = Vec::new(); for (slot_name, _) in delta.storage().maps() { - if let Some(root) = map_roots_overlay.get(slot_name) { + if let Some(root) = map_roots_overlay.get(&(account_id, slot_name.clone())) { old_map_roots.insert(slot_name.clone(), *root); } else { map_slot_names_to_fetch.push(slot_name.to_string()); @@ -547,16 +548,16 @@ impl IdxdbStore { smt_forest.stage_roots(account_id, final_roots); // Propagate this tx's post-state into the overlays so subsequent preparations - // in the same batch see these updates. + // against this same account in the batch see these updates. for asset in &updated_assets { - vault_overlay.insert(asset.vault_key(), Some(*asset)); + vault_overlay.insert((account_id, asset.vault_key()), Some(*asset)); } for vault_key in &removed_vault_keys { - vault_overlay.insert(*vault_key, None); + vault_overlay.insert((account_id, *vault_key), None); } for (slot_name, (new_root, slot_type)) in &updated_storage_slots { if *slot_type == StorageSlotType::Map { - map_roots_overlay.insert(slot_name.clone(), *new_root); + map_roots_overlay.insert((account_id, slot_name.clone()), *new_root); } } diff --git a/crates/web-client/README.md b/crates/web-client/README.md index 8f13d400..2ff2bce1 100644 --- a/crates/web-client/README.md +++ b/crates/web-client/README.md @@ -454,35 +454,32 @@ console.log(`Balance: ${balance}`); ### Batch Operations -Submit multiple operations against a single account as one atomic batch — every transaction in the batch lands together or none does. Each operation builds its own `TransactionRequest` internally; you don't have to assemble or serialize them yourself. +Submit multiple operations across one or more local accounts as one atomic batch — every transaction in the batch lands together or none does. Each operation builds its own `TransactionRequest` internally; you don't have to assemble or serialize them yourself. ```typescript const { blockNumber } = await client.transactions.batch({ - account: wallet, operations: [ - { kind: "send", to: alice, token: dagToken, amount: 50n, type: "public" }, - { kind: "send", to: bob, token: dagToken, amount: 30n, type: "public" }, - { kind: "consume", notes: pendingNotes }, + { kind: "send", account: alice, to: bob, token: dagToken, amount: 50n, type: "public" }, + { kind: "send", account: alice, to: carol, token: dagToken, amount: 30n, type: "public" }, + { kind: "consume", account: bob, notes: pendingNotes }, ], waitForConfirmation: true, }); console.log(`Batch landed in block ${blockNumber}`); ``` -Operations are discriminated by `kind`: `"send"`, `"mint"`, `"consume"`, `"swap"`, `"execute"`, and `"custom"` (escape hatch for a pre-built `TransactionRequest`). The shape of each operation mirrors the singular options object (`SendOptions`, `MintOptions`, …) minus the `account` field, which is set once at the batch level. - -V1 supports only same-account batches — every operation must execute against the `account` passed at the top level. Mixing accounts in one batch is not supported. +Operations are discriminated by `kind`: `"send"`, `"mint"`, `"consume"`, `"swap"`, `"execute"`, and `"custom"` (escape hatch for a pre-built `TransactionRequest`). Each operation specifies its executing `account`; a batch may mix any combination of tracked local accounts, and a later transaction may consume a note produced by an earlier one in the same batch. -For callers that already hold pre-built `TransactionRequest`s, `submitBatch` skips the high-level builders: +For callers that already hold pre-built `TransactionRequest`s, `submitBatch` skips the high-level builders. Pass an array of `{ account, request }` pairs: ```typescript -const { blockNumber } = await client.transactions.submitBatch(wallet, [ - request1, - request2, +const { blockNumber } = await client.transactions.submitBatch([ + { account: alice, request: request1 }, + { account: bob, request: request2 }, ]); ``` -The V1 batch primitive returns only the block number — there are no per-tx ids in the result. `waitForConfirmation` polls local sync height until it reaches `blockNumber` (rather than per-tx polling like singular `send` / `consume`). +The batch primitive returns only the block number — there are no per-tx ids in the result. `waitForConfirmation` polls local sync height until it reaches `blockNumber` (rather than per-tx polling like singular `send` / `consume`). ### Partial-Swap (PSWAP) Orders diff --git a/crates/web-client/js/__tests__/resources/transactions.test.js b/crates/web-client/js/__tests__/resources/transactions.test.js index 01293a98..f6e78bc8 100644 --- a/crates/web-client/js/__tests__/resources/transactions.test.js +++ b/crates/web-client/js/__tests__/resources/transactions.test.js @@ -41,6 +41,9 @@ function makeWasm(overrides = {}) { NoteArray: vi.fn().mockImplementation(makeNoteArray), NoteAndArgs: vi.fn().mockImplementation((note, args) => ({ note, args })), NoteAndArgsArray: vi.fn().mockReturnValue("noteAndArgsArray"), + BatchItem: vi + .fn() + .mockImplementation((accountId, request) => ({ accountId, request })), TransactionRequestBuilder: vi.fn().mockImplementation(makeTxRequestBuilder), TransactionFilter: { all: vi.fn().mockReturnValue("filterAll"), @@ -1357,15 +1360,10 @@ describe("TransactionsResource", () => { }); describe("batch + submitBatch", () => { - // Helper: a fake TransactionRequest with a .serialize() method, since - // submitBatch calls `r.serialize()` on every entry. The per-op - // builders' `new*Request` methods need to return objects with - // `.serialize()` so the batch path is exercised end-to-end. + // Helper: a fake TransactionRequest. submitBatch no longer serializes; + // the BatchItem constructor takes the TransactionRequest by reference. function fakeRequest(label = "req") { - return { - serialize: vi.fn().mockReturnValue(new Uint8Array([1, 2])), - _label: label, - }; + return { _label: label }; } it("dispatches send / mint / consume / swap / execute / custom kinds and submits", async () => { @@ -1400,36 +1398,79 @@ describe("TransactionsResource", () => { const customReq = fakeRequest("custom"); const result = await resource.batch({ - account: "0xsender", operations: [ { kind: "send", + account: "0xsender", to: "0xto", token: "0xtok", amount: 1, type: "public", }, - { kind: "mint", to: "0xto", amount: 2, type: "public" }, - { kind: "consume", notes: ["0xnoteId"] }, + { + kind: "mint", + account: "0xsender", + to: "0xto", + amount: 2, + type: "public", + }, + { kind: "consume", account: "0xsender", notes: ["0xnoteId"] }, { kind: "swap", + account: "0xsender", offer: { token: "0xt1", amount: 5 }, request: { token: "0xt2", amount: 7 }, type: "public", }, - { kind: "execute", script: "scriptHandle" }, - { kind: "custom", request: customReq }, + { kind: "execute", account: "0xsender", script: "scriptHandle" }, + { kind: "custom", account: "0xsender", request: customReq }, ], }); expect(inner.submitNewTransactionBatch).toHaveBeenCalledTimes(1); - const [accountIdArg, bytesArg] = - inner.submitNewTransactionBatch.mock.calls[0]; - expect(accountIdArg.toString()).toBe("0xsender"); - expect(bytesArg).toHaveLength(6); + const [itemsArg] = inner.submitNewTransactionBatch.mock.calls[0]; + expect(itemsArg).toHaveLength(6); + expect( + itemsArg.every((item) => item.accountId.toString() === "0xsender") + ).toBe(true); expect(result).toEqual({ blockNumber: 42 }); - // custom request.serialize() called via submitBatch path - expect(customReq.serialize).toHaveBeenCalled(); + // The custom request flows through unchanged — no serialize() round-trip + // since BatchItem carries the TransactionRequest directly. + expect(itemsArg[5].request).toBe(customReq); + }); + + it("supports operations targeting multiple distinct accounts", async () => { + const { resource, inner } = makeResource({ + newSendTransactionRequest: vi + .fn() + .mockResolvedValue(fakeRequest("send")), + newConsumeTransactionRequest: vi + .fn() + .mockResolvedValue(fakeRequest("consume")), + submitNewTransactionBatch: vi.fn().mockResolvedValue(99), + }); + + const result = await resource.batch({ + operations: [ + { + kind: "send", + account: "0xalice", + to: "0xbob", + token: "0xtok", + amount: 10, + type: "public", + }, + { kind: "consume", account: "0xbob", notes: ["0xnoteId"] }, + ], + }); + + expect(inner.submitNewTransactionBatch).toHaveBeenCalledTimes(1); + const [itemsArg] = inner.submitNewTransactionBatch.mock.calls[0]; + expect(itemsArg.map((item) => item.accountId.toString())).toEqual([ + "0xalice", + "0xbob", + ]); + expect(result).toEqual({ blockNumber: 99 }); }); it("execute kind threads foreignAccounts through ForeignAccountArray", async () => { @@ -1448,10 +1489,10 @@ describe("TransactionsResource", () => { ); await resource.batch({ - account: "0xsender", operations: [ { kind: "execute", + account: "0xsender", script: "scriptHandle", foreignAccounts: ["0xforeign1", { id: "0xforeign2" }], }, @@ -1462,29 +1503,30 @@ describe("TransactionsResource", () => { expect(wasm.ForeignAccountArray).toHaveBeenCalled(); }); - it("throws when account is missing", async () => { + it("throws when an operation is missing account", async () => { const { resource } = makeResource(); await expect( - resource.batch({ operations: [{ kind: "send" }] }) - ).rejects.toThrow(/account.*required/); + resource.batch({ + operations: [{ kind: "send" }], + }) + ).rejects.toThrow(/missing.*account/); }); it("throws when operations is empty or not an array", async () => { const { resource } = makeResource(); - await expect( - resource.batch({ account: "0xsender", operations: [] }) - ).rejects.toThrow(/non-empty array/); - await expect( - resource.batch({ account: "0xsender", operations: undefined }) - ).rejects.toThrow(/non-empty array/); + await expect(resource.batch({ operations: [] })).rejects.toThrow( + /non-empty array/ + ); + await expect(resource.batch({ operations: undefined })).rejects.toThrow( + /non-empty array/ + ); }); it("throws on unknown operation kind", async () => { const { resource } = makeResource(); await expect( resource.batch({ - account: "0xsender", - operations: [{ kind: "bogus" }], + operations: [{ kind: "bogus", account: "0xsender" }], }) ).rejects.toThrow(/unknown kind/); }); @@ -1493,17 +1535,25 @@ describe("TransactionsResource", () => { const { resource } = makeResource(); await expect( resource.batch({ - account: "0xsender", - operations: [{ kind: "custom" }], + operations: [{ kind: "custom", account: "0xsender" }], }) ).rejects.toThrow(/missing.*request/); }); - it("submitBatch rejects an empty requests array", async () => { + it("submitBatch rejects an empty items array", async () => { const { resource } = makeResource(); - await expect(resource.submitBatch("0xsender", [])).rejects.toThrow( - /non-empty array/ + await expect(resource.submitBatch([])).rejects.toThrow(/non-empty array/); + }); + + it("submitBatch throws when an item is missing account or request", async () => { + const { resource } = makeResource(); + const r = fakeRequest(); + await expect(resource.submitBatch([{ request: r }])).rejects.toThrow( + /missing.*account/ ); + await expect( + resource.submitBatch([{ account: "0xsender" }]) + ).rejects.toThrow(/missing.*request/); }); it("submitBatch with waitForConfirmation polls sync height until block lands", async () => { @@ -1518,11 +1568,17 @@ describe("TransactionsResource", () => { const r1 = fakeRequest("a"); const r2 = fakeRequest("b"); - const result = await resource.submitBatch("0xsender", [r1, r2], { - waitForConfirmation: true, - timeout: 60_000, - interval: 0, // poll immediately, no wall-clock wait in tests - }); + const result = await resource.submitBatch( + [ + { account: "0xsender", request: r1 }, + { account: "0xsender", request: r2 }, + ], + { + waitForConfirmation: true, + timeout: 60_000, + interval: 0, // poll immediately, no wall-clock wait in tests + } + ); expect(result).toEqual({ blockNumber: 100 }); expect(inner.getSyncHeight).toHaveBeenCalled(); @@ -1540,7 +1596,7 @@ describe("TransactionsResource", () => { syncStateWithTimeout: sync, }); const r = fakeRequest(); - await resource.submitBatch("0xsender", [r], { + await resource.submitBatch([{ account: "0xsender", request: r }], { waitForConfirmation: true, interval: 0, }); @@ -1555,7 +1611,7 @@ describe("TransactionsResource", () => { }); const r = fakeRequest(); await expect( - resource.submitBatch("0xsender", [r], { + resource.submitBatch([{ account: "0xsender", request: r }], { waitForConfirmation: true, timeout: 1, // 1ms — first poll already past interval: 0, diff --git a/crates/web-client/js/node-index.js b/crates/web-client/js/node-index.js index c5e3d668..f70955d6 100644 --- a/crates/web-client/js/node-index.js +++ b/crates/web-client/js/node-index.js @@ -274,6 +274,9 @@ export const StorageSlot = /* @__PURE__ */ _reexport("StorageSlot"); export const SyncSummary = /* @__PURE__ */ _reexport("SyncSummary"); export const TokenSymbol = /* @__PURE__ */ _reexport("TokenSymbol"); export const TransactionArgs = /* @__PURE__ */ _reexport("TransactionArgs"); + +// Transaction types +export const BatchItem = /* @__PURE__ */ _reexport("BatchItem"); export const TransactionFilter = /* @__PURE__ */ _reexport("TransactionFilter"); export const TransactionId = /* @__PURE__ */ _reexport("TransactionId"); export const TransactionProver = /* @__PURE__ */ _reexport("TransactionProver"); diff --git a/crates/web-client/js/resources/transactions.js b/crates/web-client/js/resources/transactions.js index 6a368b63..111ff9ba 100644 --- a/crates/web-client/js/resources/transactions.js +++ b/crates/web-client/js/resources/transactions.js @@ -341,63 +341,50 @@ export class TransactionsResource { } /** - * Submit a heterogeneous batch of operations against a single account. All - * operations are executed, proven individually and as a batch, and submitted + * Submit a heterogeneous batch of operations across one or more local + * accounts. Each operation specifies which account it targets via `account`. + * Operations are executed, proven individually and as a batch, and submitted * atomically — either every tx in the batch lands or none does. * - * @param {BatchOptions} opts - Batch options including the account, operations array, and confirmation settings. + * @param {BatchOptions} opts - Batch options including the operations array and confirmation settings. * @returns {Promise} The block number the batch was accepted into. */ async batch(opts) { this.#client.assertNotTerminated(); const wasm = await this.#getWasm(); - if (!opts || !opts.account) { - throw new Error("batch: `account` is required"); - } - if (!Array.isArray(opts.operations) || opts.operations.length === 0) { + if ( + !opts || + !Array.isArray(opts.operations) || + opts.operations.length === 0 + ) { throw new Error("batch: `operations` must be a non-empty array"); } - // Build each TransactionRequest. Per-op builders all use the batch-level - // `account` — V1 only supports same-account batches, mirroring the Rust - // constraint. We forward `opts.account` into each per-op options object so - // the existing builders' `resolveAccountRef` produces fresh AccountIds - // when needed. - const requests = []; + // Build each TransactionRequest. Every operation carries its own + // `account` — same-account batches just repeat the same value. + const items = []; for (let i = 0; i < opts.operations.length; i++) { const op = opts.operations[i]; + if (!op?.account) { + throw new Error(`batch: operation[${i}] is missing \`account\``); + } let built; switch (op?.kind) { case "send": - built = await this.#buildSendRequest( - { ...op, account: opts.account }, - wasm - ); + built = await this.#buildSendRequest(op, wasm); break; case "mint": - built = await this.#buildMintRequest( - { ...op, account: opts.account }, - wasm - ); + built = await this.#buildMintRequest(op, wasm); break; case "consume": - built = await this.#buildConsumeRequest( - { ...op, account: opts.account }, - wasm - ); + built = await this.#buildConsumeRequest(op, wasm); break; case "swap": - built = await this.#buildSwapRequest( - { ...op, account: opts.account }, - wasm - ); + built = await this.#buildSwapRequest(op, wasm); break; case "execute": - built = this.#buildExecuteRequest( - { ...op, account: opts.account }, - wasm - ); + built = this.#buildExecuteRequest(op, wasm); break; case "custom": if (!op.request) { @@ -412,37 +399,45 @@ export class TransactionsResource { `batch: operation[${i}] has unknown kind "${op?.kind}"` ); } - requests.push(built.request); + items.push({ account: op.account, request: built.request }); } - return this.submitBatch(opts.account, requests, opts); + return this.submitBatch(items, opts); } /** * Submit pre-built TransactionRequests as an atomic batch. Lower-level - * counterpart of `batch()` — for callers that already have built requests in - * hand. Equivalent to `submit()` but plural. + * counterpart of `batch()` — for callers that already have built requests + * paired with their target accounts. * - * @param {AccountRef} account - The account executing the batch. - * @param {TransactionRequest[]} requests - Pre-built transaction requests. + * @param {Array<{ account: AccountRef, request: TransactionRequest }>} items - Per-tx (account, request) pairs. * @param {object} [options] - Optional settings (waitForConfirmation, timeout). * The batch is proved with the client's configured prover; the V1 batch API * has no per-call prover override. * @returns {Promise} The block number the batch was accepted into. */ - async submitBatch(account, requests, options) { + async submitBatch(items, options) { this.#client.assertNotTerminated(); const wasm = await this.#getWasm(); - if (!Array.isArray(requests) || requests.length === 0) { - throw new Error("submitBatch: `requests` must be a non-empty array"); + if (!Array.isArray(items) || items.length === 0) { + throw new Error("submitBatch: `items` must be a non-empty array"); } - const accountId = resolveAccountRef(account, wasm); - const blockNumber = await this.#inner.submitNewTransactionBatch( - accountId, - requests.map((r) => r.serialize()) - ); + const wasmItems = items.map((item, i) => { + if (!item?.account) { + throw new Error(`submitBatch: items[${i}] is missing \`account\``); + } + if (!item?.request) { + throw new Error(`submitBatch: items[${i}] is missing \`request\``); + } + return new wasm.BatchItem( + resolveAccountRef(item.account, wasm), + item.request + ); + }); + + const blockNumber = await this.#inner.submitNewTransactionBatch(wasmItems); if (options?.waitForConfirmation) { await this.#waitForBlock(blockNumber, options); diff --git a/crates/web-client/js/types/api-types.d.ts b/crates/web-client/js/types/api-types.d.ts index 097c433c..30c69af4 100644 --- a/crates/web-client/js/types/api-types.d.ts +++ b/crates/web-client/js/types/api-types.d.ts @@ -401,13 +401,14 @@ export interface ConsumeAllOptions extends TransactionOptions { /** * A single operation inside a transaction batch. The shape mirrors the - * singular options types (`SendOptions`, `MintOptions`, ...) minus the - * `account` field — the executing account is set once at the batch level - * and shared by every operation (V1 single-account constraint). + * singular options types (`SendOptions`, `MintOptions`, ...). Each + * operation specifies which local account executes it via `account`; a + * batch may mix operations across any combination of local accounts. */ export type BatchOperation = | { kind: "send"; + account: AccountRef; to: AccountRef; token: AccountRef; amount: number | bigint; @@ -417,16 +418,19 @@ export type BatchOperation = } | { kind: "mint"; + account: AccountRef; to: AccountRef; amount: number | bigint; type?: NoteVisibility; } | { kind: "consume"; + account: AccountRef; notes: NoteInput | NoteInput[]; } | { kind: "swap"; + account: AccountRef; offer: Asset; request: Asset; type?: NoteVisibility; @@ -434,6 +438,7 @@ export type BatchOperation = } | { kind: "execute"; + account: AccountRef; script: TransactionScript; foreignAccounts?: ( | AccountRef @@ -443,24 +448,24 @@ export type BatchOperation = | { /** Escape hatch for pre-built TransactionRequests. */ kind: "custom"; + account: AccountRef; request: TransactionRequest; }; export interface BatchOptions { - /** The account executing every operation in the batch (single-account in V1). */ - account: AccountRef; /** Operations to execute atomically as a batch. Must be non-empty. */ operations: BatchOperation[]; /** * Wait until the batch's block has been observed in the local sync height. - * Differs from singular `waitForConfirmation`: the V1 batch API returns - * only a block number, so we poll chain height rather than per-tx status. + * Differs from singular `waitForConfirmation`: the batch API returns only + * a block number, so we poll chain height rather than per-tx status. */ waitForConfirmation?: boolean; /** Wall-clock polling timeout for `waitForConfirmation` (default 60_000ms). */ timeout?: number; } + export interface BatchSubmitResult { /** The block number the batch was accepted into. */ blockNumber: number; @@ -906,15 +911,12 @@ export interface TransactionsResource { ): Promise; /** - * Execute a heterogeneous batch of operations against a single account. - * Each operation is built, proven individually and as a batch, and all - * operations are submitted atomically — either every tx in the batch - * lands or none does. + * Execute a heterogeneous batch of operations across one or more local + * accounts. Each operation specifies its executing `account`. Operations + * are built, proven individually and as a batch, and submitted atomically — + * either every tx in the batch lands or none does. * - * V1 supports only same-account batches (mirrors the underlying Rust - * `Client::new_transaction_batch()` constraint). - * - * @param options - Batch options including the account and operations. + * @param options - Batch options including the operations array. */ batch(options: BatchOptions): Promise; @@ -923,14 +925,12 @@ export interface TransactionsResource { * counterpart of {@link submit} — for callers that already have built * requests in hand and want to skip the high-level operation builders. * - * @param account - The account executing every transaction in the batch. - * @param requests - Pre-built transaction requests (must be non-empty). + * @param items - Per-tx (account, request) pairs (must be non-empty). * @param options - Optional batch settings (waitForConfirmation, timeout, prover). */ submitBatch( - account: AccountRef, - requests: TransactionRequest[], - options?: Omit + items: { account: AccountRef; request: TransactionRequest }[], + options?: Omit ): Promise; /** Execute a program (view call) and return the resulting stack output. */ diff --git a/crates/web-client/playwright.config.ts b/crates/web-client/playwright.config.ts index af024b95..b6d909c9 100644 --- a/crates/web-client/playwright.config.ts +++ b/crates/web-client/playwright.config.ts @@ -63,6 +63,7 @@ const ciShardProjects = process.env.CI "test/new_transactions_mint_and_misc.test.ts", "test/swap_transactions.test.ts", "test/pswap_transactions.test.ts", + "test/batch.browser.test.ts", ], testIgnore: browserTestIgnore, }, diff --git a/crates/web-client/src/models/batch_item.rs b/crates/web-client/src/models/batch_item.rs new file mode 100644 index 00000000..46fdb0f4 --- /dev/null +++ b/crates/web-client/src/models/batch_item.rs @@ -0,0 +1,40 @@ +use js_export_macro::js_export; + +use crate::models::account_id::AccountId; +use crate::models::transaction_request::TransactionRequest; + +/// A single (account, request) pair to be executed in a transaction batch. +/// +/// Used as the element type of `WebClient::submit_new_transaction_batch`'s input — keeping the +/// account id and its transaction request together at the type level removes the mismatched-arrays +/// failure mode parallel `Vec`s would have. +#[derive(Clone)] +#[js_export] +pub struct BatchItem { + account_id: AccountId, + request: TransactionRequest, +} + +#[js_export] +impl BatchItem { + /// Creates a new (account, request) pair for a transaction batch. + #[js_export(constructor)] + pub fn new(account_id: &AccountId, request: &TransactionRequest) -> BatchItem { + BatchItem { + account_id: *account_id, + request: request.clone(), + } + } +} + +impl BatchItem { + pub(crate) fn account_id(&self) -> &AccountId { + &self.account_id + } + + pub(crate) fn request(&self) -> &TransactionRequest { + &self.request + } +} + +impl_napi_from_value!(BatchItem); diff --git a/crates/web-client/src/models/mod.rs b/crates/web-client/src/models/mod.rs index fcb041cd..852639fd 100644 --- a/crates/web-client/src/models/mod.rs +++ b/crates/web-client/src/models/mod.rs @@ -51,6 +51,7 @@ pub mod auth; pub mod auth_scheme; pub mod auth_secret_key; pub mod basic_fungible_faucet_component; +pub mod batch_item; pub mod block_header; pub mod code_builder; pub mod committed_note; diff --git a/crates/web-client/src/new_transactions.rs b/crates/web-client/src/new_transactions.rs index 3b4e7a0d..6931a132 100644 --- a/crates/web-client/src/new_transactions.rs +++ b/crates/web-client/src/new_transactions.rs @@ -23,6 +23,7 @@ use miden_client::transaction::{ use crate::models::NoteType; use crate::models::account_id::AccountId; use crate::models::advice_inputs::AdviceInputs; +use crate::models::batch_item::BatchItem; use crate::models::eth_address::EthAddress; use crate::models::felt::Felt; use crate::models::miden_arrays::{FeltArray, ForeignAccountArray}; @@ -35,8 +36,7 @@ use crate::models::transaction_result::TransactionResult; use crate::models::transaction_script::TransactionScript; use crate::models::transaction_store_update::TransactionStoreUpdate; use crate::models::transaction_summary::TransactionSummary; -use crate::platform::{JsBytes, JsErr, from_str_err, js_u64_to_u64, maybe_wrap_send}; -use crate::utils::deserialize_from_bytes; +use crate::platform::{JsErr, from_str_err, js_u64_to_u64, maybe_wrap_send}; use crate::{WebClient, js_error_with_context}; #[js_export] @@ -382,40 +382,24 @@ impl WebClient { Ok(tx_id) } - /// Executes a batch of transactions against the specified account, proves them individually - /// and as a batch, submits the batch to the network, and atomically applies the per-tx - /// updates to the local store. Returns the block number the batch was accepted into. + /// Executes a batch of transactions across one or more local accounts, proves them + /// individually and as a batch, submits the batch to the network, and atomically applies + /// the per-tx updates to the local store. Returns the block number the batch was accepted + /// into. /// - /// All transactions must target the same local account — the `account_id` argument. - /// Each element of `transaction_requests` is the serialized-bytes form of a - /// `TransactionRequest` (obtained via `tx_request.serialize()`) - // TODO V2: support multi-account batches + /// Each [`BatchItem`] pairs the executing account with its transaction request, so the + /// pairing is enforced at the type level — there's no way to call this with mismatched + /// arrays. #[js_export(js_name = "submitNewTransactionBatch")] - pub async fn submit_new_transaction_batch( - &self, - account_id: &AccountId, - transaction_requests: Vec, - ) -> Result { + pub async fn submit_new_transaction_batch(&self, items: Vec) -> Result { let mut guard = self.get_mut_inner().await; let client = guard.as_mut().ok_or_else(|| from_str_err("Client not initialized"))?; - let native_account_id: miden_client::account::AccountId = account_id.into(); - - // Deserialize all requests up front so we fail early on malformed input. - let mut native_reqs: Vec = - Vec::with_capacity(transaction_requests.len()); - for bytes in &transaction_requests { - let req = deserialize_from_bytes::(bytes).map_err(|err| { - from_str_err(&format!("failed to deserialize transaction request: {err:?}")) - })?; - native_reqs.push(req); - } - // `new_transaction_batch()` is now a synchronous builder constructor that takes no - // account id; the target account is supplied per-transaction via `push`. This wrapper - // keeps its single-account contract by pushing every request against `native_account_id`. let mut builder = client.new_transaction_batch(); - for native_req in native_reqs { + for item in &items { + let native_account_id: NativeAccountId = item.account_id().into(); + let native_req: NativeTransactionRequest = item.request().into(); builder = maybe_wrap_send(Box::pin(builder.push(native_account_id, native_req))) .await .map_err(|err| js_error_with_context(err, "failed to push transaction to batch"))?; diff --git a/crates/web-client/test/batch.browser.test.ts b/crates/web-client/test/batch.browser.test.ts index cb342b41..47238ba3 100644 --- a/crates/web-client/test/batch.browser.test.ts +++ b/crates/web-client/test/batch.browser.test.ts @@ -57,9 +57,15 @@ const submitTwoTxBatch = async ( null ); - const blockNum = await client.submitNewTransactionBatch(senderAccountId, [ - sendRequest1.serialize(), - sendRequest2.serialize(), + const blockNum = await client.submitNewTransactionBatch([ + new window.BatchItem( + window.AccountId.fromHex(_senderAccount), + sendRequest1 + ), + new window.BatchItem( + window.AccountId.fromHex(_senderAccount), + sendRequest2 + ), ]); // Poll until the sender nonce has advanced by 2, giving the node time to @@ -86,6 +92,284 @@ const submitTwoTxBatch = async ( ); }; +interface MultiAccountBatchResult { + blockNum: number; + nonceADelta: string; + nonceBDelta: string; + aBalance: string; + bBalance: string; +} + +const submitCrossAccountBatch = async ( + testingPage: Page, + accountA: string, + accountB: string, + faucetAccount: string, + transferAmount: bigint +): Promise => { + return await testingPage.evaluate( + async ({ _accountA, _accountB, _faucet, _transferAmount }) => { + const client = window.client; + await client.syncState(); + + const idA = window.AccountId.fromHex(_accountA); + const idB = window.AccountId.fromHex(_accountB); + const faucetId = window.AccountId.fromHex(_faucet); + + const beforeA = await client.getAccount(idA); + const beforeB = await client.getAccount(idB); + const nonceABefore = BigInt(beforeA!.nonce()!.toString()); + const nonceBBefore = BigInt(beforeB!.nonce()!.toString()); + + // tx1 (A → B): P2ID transfer. Extract the expected output note so tx2 + // can consume it in the same batch. + const sendRequest = await client.newSendTransactionRequest( + idA, + idB, + faucetId, + window.NoteType.Private, + BigInt(_transferAmount), + null, + null + ); + const expectedNotes = sendRequest.expectedOutputOwnNotes(); + if (expectedNotes.length !== 1) { + throw new Error( + `expected exactly 1 output note from send request, got ${expectedNotes.length}` + ); + } + const inBatchNote = expectedNotes[0]; + + // tx2 (B): consume the in-batch note produced by tx1. + const consumeRequest = await client.newConsumeTransactionRequest([ + inBatchNote, + ]); + + const blockNum = await client.submitNewTransactionBatch([ + new window.BatchItem(window.AccountId.fromHex(_accountA), sendRequest), + new window.BatchItem( + window.AccountId.fromHex(_accountB), + consumeRequest + ), + ]); + + // Poll until both accounts' nonces advance by 1. + let nonceAAfter = nonceABefore; + let nonceBAfter = nonceBBefore; + for (let attempt = 0; attempt < 60; attempt++) { + await client.syncState(); + const afterA = await client.getAccount(idA); + const afterB = await client.getAccount(idB); + nonceAAfter = BigInt(afterA!.nonce()!.toString()); + nonceBAfter = BigInt(afterB!.nonce()!.toString()); + if ( + nonceAAfter >= nonceABefore + BigInt(1) && + nonceBAfter >= nonceBBefore + BigInt(1) + ) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + + const finalA = await client.getAccount(idA); + const finalB = await client.getAccount(idB); + const aBalance = finalA!.vault().getBalance(faucetId).toString(); + const bBalance = finalB!.vault().getBalance(faucetId).toString(); + + return { + blockNum, + nonceADelta: (nonceAAfter - nonceABefore).toString(), + nonceBDelta: (nonceBAfter - nonceBBefore).toString(), + aBalance, + bBalance, + }; + }, + { + _accountA: accountA, + _accountB: accountB, + _faucet: faucetAccount, + _transferAmount: transferAmount.toString(), + } + ); +}; + +interface InterleavedBatchResult { + blockNum: number; + nonceADelta: string; + nonceBDelta: string; +} + +const submitInterleavedBatch = async ( + testingPage: Page, + accountA: string, + accountB: string, + faucetAccount: string, + transferAmount: bigint +): Promise => { + // A→B, B→A, A→B in order. Forces the in-memory batch data store to + // serve A's post-push-1 state to push 3 even though push 2 targets B. + return await testingPage.evaluate( + async ({ _accountA, _accountB, _faucet, _transferAmount }) => { + const client = window.client; + await client.syncState(); + + const idA = window.AccountId.fromHex(_accountA); + const idB = window.AccountId.fromHex(_accountB); + const faucetId = window.AccountId.fromHex(_faucet); + + const beforeA = await client.getAccount(idA); + const beforeB = await client.getAccount(idB); + const nonceABefore = BigInt(beforeA!.nonce()!.toString()); + const nonceBBefore = BigInt(beforeB!.nonce()!.toString()); + + const reqAtoBFirst = await client.newSendTransactionRequest( + idA, + idB, + faucetId, + window.NoteType.Private, + BigInt(_transferAmount), + null, + null + ); + const reqBtoA = await client.newSendTransactionRequest( + idB, + idA, + faucetId, + window.NoteType.Private, + BigInt(_transferAmount), + null, + null + ); + const reqAtoBSecond = await client.newSendTransactionRequest( + idA, + idB, + faucetId, + window.NoteType.Private, + BigInt(_transferAmount), + null, + null + ); + + const blockNum = await client.submitNewTransactionBatch([ + new window.BatchItem(window.AccountId.fromHex(_accountA), reqAtoBFirst), + new window.BatchItem(window.AccountId.fromHex(_accountB), reqBtoA), + new window.BatchItem( + window.AccountId.fromHex(_accountA), + reqAtoBSecond + ), + ]); + + // A advances by 2, B advances by 1. + let nonceAAfter = nonceABefore; + let nonceBAfter = nonceBBefore; + for (let attempt = 0; attempt < 60; attempt++) { + await client.syncState(); + const afterA = await client.getAccount(idA); + const afterB = await client.getAccount(idB); + nonceAAfter = BigInt(afterA!.nonce()!.toString()); + nonceBAfter = BigInt(afterB!.nonce()!.toString()); + if ( + nonceAAfter >= nonceABefore + BigInt(2) && + nonceBAfter >= nonceBBefore + BigInt(1) + ) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + + return { + blockNum, + nonceADelta: (nonceAAfter - nonceABefore).toString(), + nonceBDelta: (nonceBAfter - nonceBBefore).toString(), + }; + }, + { + _accountA: accountA, + _accountB: accountB, + _faucet: faucetAccount, + _transferAmount: transferAmount.toString(), + } + ); +}; + +const submitDuplicateNoteBatch = async ( + testingPage: Page, + accountA: string, + accountB: string, + faucetAccount: string +): Promise<{ rejected: boolean; errorMessage: string }> => { + return await testingPage.evaluate( + async ({ _accountA, _accountB, _faucet }) => { + const client = window.client; + await client.syncState(); + + const idA = window.AccountId.fromHex(_accountA); + const idB = window.AccountId.fromHex(_accountB); + const faucetId = window.AccountId.fromHex(_faucet); + + // Mint a private note destined for A (so the note exists on-chain and + // both A and B can attempt to consume it). + const mintRequest = await client.newMintTransactionRequest( + idA, + faucetId, + window.NoteType.Private, + BigInt(100) + ); + const mintUpdate = await window.helpers.executeAndApplyTransaction( + faucetId, + mintRequest, + undefined + ); + const outputNoteId = mintUpdate + .executedTransaction() + .outputNotes() + .notes()[0] + .id() + .toString(); + await window.helpers.waitForTransaction( + mintUpdate.executedTransaction().id().toHex() + ); + + // Resolve the input-note record so we can mint two fresh `Note` JS + // proxies — each consume request consumes its array contents through + // wasm-bindgen, so reusing the same proxy would invalidate the second. + await client.syncState(); + const inputRecord = await client.getInputNote(outputNoteId); + if (!inputRecord) { + throw new Error(`Could not find minted note ${outputNoteId} in store`); + } + + // Both consume requests target the same note id. The batch must reject + // the second push with a DuplicateInputNote error before reaching the + // node. + const reqA = await client.newConsumeTransactionRequest([ + inputRecord.toNote(), + ]); + const reqB = await client.newConsumeTransactionRequest([ + inputRecord.toNote(), + ]); + + try { + await client.submitNewTransactionBatch([ + new window.BatchItem(window.AccountId.fromHex(_accountA), reqA), + new window.BatchItem(window.AccountId.fromHex(_accountB), reqB), + ]); + return { rejected: false, errorMessage: "" }; + } catch (err) { + return { + rejected: true, + errorMessage: String(err instanceof Error ? err.message : err), + }; + } + }, + { + _accountA: accountA, + _accountB: accountB, + _faucet: faucetAccount, + } + ); +}; + test.describe("submitNewTransactionBatch tests", () => { test("2-tx batch advances sender nonce by exactly 2", async ({ page }) => { test.setTimeout(900000); @@ -113,4 +397,86 @@ test.describe("submitNewTransactionBatch tests", () => { const delta = BigInt(result.nonceAfter) - BigInt(result.nonceBefore); expect(delta).toEqual(BigInt(2)); }); + + test("cross-account batch: A sends, B consumes the in-batch note", async ({ + page, + }) => { + test.setTimeout(900000); + + // Both accounts get pre-funded so each has a partial (not full-state) + // delta in the batch — the batch apply path requires partial deltas. + const { accountId: accountA, faucetId } = await setupWalletAndFaucet(page); + const { accountId: accountB } = await setupWalletAndFaucet(page); + await mintAndConsumeTransaction(page, accountA, faucetId); + await mintAndConsumeTransaction(page, accountB, faucetId); + + const transferAmount = BigInt(40); + const result = await submitCrossAccountBatch( + page, + accountA, + accountB, + faucetId, + transferAmount + ); + + expect(result.blockNum).toBeGreaterThan(0); + // Each account contributes exactly one transaction. + expect(result.nonceADelta).toEqual("1"); + expect(result.nonceBDelta).toEqual("1"); + // A pre-mint of 1000 (from mintAndConsumeTransaction) minus the transferred + // amount; B holds its pre-mint plus the transferred amount. + expect(result.aBalance).toEqual((BigInt(1000) - transferAmount).toString()); + expect(result.bBalance).toEqual((BigInt(1000) + transferAmount).toString()); + }); + + test("interleaved A→B→A pushes share A's in-batch state", async ({ + page, + }) => { + test.setTimeout(900000); + + const { accountId: accountA, faucetId } = await setupWalletAndFaucet(page); + const { accountId: accountB } = await setupWalletAndFaucet(page); + await mintAndConsumeTransaction(page, accountA, faucetId); + await mintAndConsumeTransaction(page, accountB, faucetId); + + const transferAmount = BigInt(20); + const result = await submitInterleavedBatch( + page, + accountA, + accountB, + faucetId, + transferAmount + ); + + expect(result.blockNum).toBeGreaterThan(0); + // A pushed twice (positions 0 and 2). If A's cache weren't reused on + // push 3, the third tx's `initial_account_state` wouldn't match the + // chain A would have produced and the node would reject the batch — + // the delta would never reach 2. + expect(result.nonceADelta).toEqual("2"); + expect(result.nonceBDelta).toEqual("1"); + }); + + test("duplicate input note across accounts is rejected at push time", async ({ + page, + }) => { + test.setTimeout(900000); + + const { accountId: accountA, faucetId } = await setupWalletAndFaucet(page); + const { accountId: accountB } = await setupWalletAndFaucet(page); + // Pre-fund A so its first batch tx has a partial delta. + await mintAndConsumeTransaction(page, accountA, faucetId); + + const { rejected, errorMessage } = await submitDuplicateNoteBatch( + page, + accountA, + accountB, + faucetId + ); + + expect(rejected).toBe(true); + // BatchBuilderError::DuplicateInputNote formats as "input note is + // already consumed by an earlier transaction in this batch". + expect(errorMessage.toLowerCase()).toContain("already consumed"); + }); }); diff --git a/crates/web-client/test/global.test.d.ts b/crates/web-client/test/global.test.d.ts index 0b84b0e3..9ad4ad97 100644 --- a/crates/web-client/test/global.test.d.ts +++ b/crates/web-client/test/global.test.d.ts @@ -18,6 +18,7 @@ import { AuthFalcon512RpoMultisigConfig, AuthSecretKey, BasicFungibleFaucetComponent, + BatchItem, ConsumableNoteRecord, Endpoint, Felt, @@ -109,6 +110,7 @@ declare global { AuthFalcon512RpoMultisigConfig: typeof AuthFalcon512RpoMultisigConfig; AuthSecretKey: typeof AuthSecretKey; BasicFungibleFaucetComponent: typeof BasicFungibleFaucetComponent; + BatchItem: typeof BatchItem; ConsumableNoteRecord: typeof ConsumableNoteRecord; Endpoint: typeof Endpoint; Felt: typeof Felt; diff --git a/docs/external/src/web-client/library/transactions.md b/docs/external/src/web-client/library/transactions.md index b0a53303..a41bff33 100644 --- a/docs/external/src/web-client/library/transactions.md +++ b/docs/external/src/web-client/library/transactions.md @@ -87,15 +87,14 @@ Check status using methods on the `TransactionStatus` object: ## Batch Operations -Submit multiple operations against a single account as one atomic batch — every transaction in the batch lands together or none does. Each operation builds its own `TransactionRequest` internally, so consumers don't have to assemble or serialize them by hand. +Submit multiple operations across one or more local accounts as one atomic batch — every transaction in the batch lands together or none does. Each operation builds its own `TransactionRequest` internally, so consumers don't have to assemble or serialize them by hand. ```typescript const { blockNumber } = await client.transactions.batch({ - account: wallet, operations: [ - { kind: "send", to: alice, token: dagToken, amount: 50n, type: "public" }, - { kind: "send", to: bob, token: dagToken, amount: 30n, type: "public" }, - { kind: "consume", notes: pendingNotes }, + { kind: "send", account: alice, to: bob, token: dagToken, amount: 50n, type: "public" }, + { kind: "send", account: alice, to: carol, token: dagToken, amount: 30n, type: "public" }, + { kind: "consume", account: bob, notes: pendingNotes }, ], waitForConfirmation: true, }); @@ -104,31 +103,36 @@ console.log(`Batch landed in block ${blockNumber}`); ### Operation kinds -`BatchOperation` is a discriminated union on `kind`. Each shape mirrors the singular options object (`SendOptions`, `MintOptions`, …) minus the `account` field, which is set once at the batch level: +`BatchOperation` is a discriminated union on `kind`. Each shape mirrors the singular options object (`SendOptions`, `MintOptions`, …). Every operation specifies which local account executes it via `account`: | `kind` | Fields | |---|---| -| `"send"` | `to`, `token`, `amount`, `type?`, `reclaimAfter?`, `timelockUntil?` | -| `"mint"` | `to`, `amount`, `type?` | -| `"consume"` | `notes` (single `NoteInput` or array) | -| `"swap"` | `offer: { token, amount }`, `request: { token, amount }`, `type?`, `paybackType?` | -| `"execute"` | `script`, `foreignAccounts?` | -| `"custom"` | `request: TransactionRequest` (escape hatch for pre-built requests) | +| `"send"` | `account`, `to`, `token`, `amount`, `type?`, `reclaimAfter?`, `timelockUntil?` | +| `"mint"` | `account`, `to`, `amount`, `type?` | +| `"consume"` | `account`, `notes` (single `NoteInput` or array) | +| `"swap"` | `account`, `offer: { token, amount }`, `request: { token, amount }`, `type?`, `paybackType?` | +| `"execute"` | `account`, `script`, `foreignAccounts?` | +| `"custom"` | `account`, `request: TransactionRequest` (escape hatch for pre-built requests) | -### V1 constraints +### Cross-account flows -- **Single account.** Every operation runs against the `account` passed at the top level. Mixing accounts across operations throws — V2 will lift this constraint. +A later transaction may consume a note produced by an earlier transaction in the same batch — even when the producer and consumer target different accounts. Push order must respect producer-before-consumer. + +### Constraints + +- **All accounts must be tracked.** Every `account` referenced by an operation must be registered with the client. Pushing for an unknown account fails at submit time with `AccountDataNotFound`. - **No per-tx ids in the result.** `batch` returns `{ blockNumber }`. To inspect individual transactions in the batch, sync state and query with `client.transactions.list()` after `waitForConfirmation` succeeds. - **Atomicity is at the batch level.** Either all transactions in the batch land or none do — this differs from `Promise.all([send, send, send])` of singular calls (which can partially succeed). +- **No duplicate input notes.** A note consumed by one transaction in the batch cannot be consumed by another — globally across accounts. ### `submitBatch` — pre-built requests -For callers that already hold pre-built `TransactionRequest`s, `submitBatch` skips the high-level builders: +For callers that already hold pre-built `TransactionRequest`s, `submitBatch` skips the high-level builders. Each item pairs the executing account with its request: ```typescript -const { blockNumber } = await client.transactions.submitBatch(wallet, [ - request1, - request2, +const { blockNumber } = await client.transactions.submitBatch([ + { account: alice, request: request1 }, + { account: bob, request: request2 }, ]); ``` @@ -136,4 +140,4 @@ This is the plural counterpart of `client.transactions.submit(account, request)` ### `waitForConfirmation` semantics -The V1 batch primitive returns only a block number — there are no per-tx ids to poll. Setting `waitForConfirmation: true` polls the local sync height until it reaches `blockNumber` (rather than per-transaction polling like singular `send` / `consume` do). The `timeout` option still applies; default is 60 seconds. +The batch primitive returns only a block number — there are no per-tx ids to poll. Setting `waitForConfirmation: true` polls the local sync height until it reaches `blockNumber` (rather than per-transaction polling like singular `send` / `consume` do). The `timeout` option still applies; default is 60 seconds. diff --git a/packages/react-sdk/CLAUDE.md b/packages/react-sdk/CLAUDE.md index c5d44d0f..e246a5bf 100644 --- a/packages/react-sdk/CLAUDE.md +++ b/packages/react-sdk/CLAUDE.md @@ -128,6 +128,26 @@ await multiSend({ }); ``` +### Submit a Multi-Transaction Batch +```tsx +const { batch } = useBatch(); +const client = useMidenClient(); + +const sendReq = await client.newSendTransactionRequest( + alice, bob, token, NoteType.Private, 50n, null, null +); +const consumeReq = await client.newConsumeTransactionRequest([note]); + +const { blockNumber } = await batch({ + items: [ + { account: alice, request: sendReq }, + { account: bob, request: consumeReq }, // may consume notes from earlier items in the batch + ], +}); +``` + +Each item pairs a tracked account with a pre-built `TransactionRequest`. The batch is proven and submitted atomically — either every tx lands or none. Items can target multiple accounts; later items may consume notes produced by earlier ones (push order must respect producer-before-consumer). + ### Claim Notes ```tsx const { consume } = useConsume(); @@ -425,6 +445,7 @@ Query hooks return `{ ...data, isLoading, error, refetch }`. Mutation hooks retu | `useImportStore()` / `useExportStore()` | store import/export | bytes / `void` | | `useSend()` | `send({ from, to, assetId, amount, noteType })` | `SendResult` (with `txId`, `note`) | | `useMultiSend()` | `multiSend({ from, recipients })` | `TransactionResult` | +| `useBatch()` | `batch({ items })` — items are `{ account, request }` pairs | `BatchResult` (with `blockNumber`) | | `useMint()` | `mint({ faucetId, to, amount })` | `TransactionResult` | | `useBridge()` | `bridge({ from, bridgeAccount, assetId, amount, destinationNetwork, destinationAddress })` | `TransactionResult` (emits an AggLayer B2AGG bridge-out note) | | `useConsume()` | `consume({ accountId, notes })` | `TransactionResult` | diff --git a/packages/react-sdk/README.md b/packages/react-sdk/README.md index c2866872..0c72344d 100644 --- a/packages/react-sdk/README.md +++ b/packages/react-sdk/README.md @@ -777,6 +777,59 @@ function MultiSendButton() { } ``` +#### `useBatch()` + +Submit multiple transactions across one or more tracked accounts as one atomic +batch — every tx lands together or none does. Each item pairs a local account +with a pre-built `TransactionRequest`. Later items may consume notes produced by +earlier ones (even across accounts); push order must respect +producer-before-consumer. The underlying primitive returns a block number +rather than per-tx ids, so the hook's result is `{ blockNumber }`. + +Built-in features: +- **Auto pre-sync** before submit (disable with `skipSync: true`) +- **Concurrency guard** rejects a second `batch()` while the first is still + in flight (`BATCH_BUSY`) +- **Atomicity** — the batch path uses the same proven-batch RPC the underlying + `submitNewTransactionBatch` exposes; the store applies all per-tx updates + in one IndexedDB transaction + +```tsx +import { useBatch, useMidenClient } from '@miden-sdk/react'; +import { BatchItem, NoteType } from '@miden-sdk/miden-sdk'; + +function BatchButton() { + const { batch, isLoading, stage } = useBatch(); + const client = useMidenClient(); + + const handleBatch = async () => { + const sendReq = await client.newSendTransactionRequest( + alice, bob, token, NoteType.Private, 50n, null, null, + ); + const consumeReq = await client.newConsumeTransactionRequest([incomingNote]); + + const { blockNumber } = await batch({ + items: [ + { account: alice, request: sendReq }, + { account: bob, request: consumeReq }, // can consume notes from earlier items + ], + }); + console.log('Batch landed in block', blockNumber); + }; + + return ( + + ); +} +``` + +Pass `skipSync: true` if you've already synced and want to avoid the pre-submit +round-trip. The hook never serializes the requests — the WASM `BatchItem` +constructor takes the `TransactionRequest` by reference, so there's no +hidden `.serialize()` cost on the JS side. + #### `useInternalTransfer()` Create a P2ID note and immediately consume it. This is useful for transfers @@ -1767,6 +1820,9 @@ import type { SendOptions, MultiSendRecipient, MultiSendOptions, + BatchItemInput, + BatchOptions, + BatchResult, InternalTransferOptions, InternalTransferChainOptions, InternalTransferResult, diff --git a/packages/react-sdk/src/__tests__/hooks/useBatch.test.tsx b/packages/react-sdk/src/__tests__/hooks/useBatch.test.tsx new file mode 100644 index 00000000..77870712 --- /dev/null +++ b/packages/react-sdk/src/__tests__/hooks/useBatch.test.tsx @@ -0,0 +1,268 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useBatch } from "../../hooks/useBatch"; +import { useMiden } from "../../context/MidenProvider"; +import { useMidenStore } from "../../store/MidenStore"; +import { createMockWebClient } from "../mocks/miden-sdk"; + +vi.mock("../../context/MidenProvider", () => ({ + useMiden: vi.fn(), +})); + +vi.mock("@miden-sdk/miden-sdk", () => ({ + BatchItem: vi.fn().mockImplementation((account, request) => ({ + account, + request, + })), +})); + +vi.mock("../../utils/accountParsing", () => ({ + parseAccountId: vi.fn((ref: string) => ({ toString: () => ref })), +})); + +const mockUseMiden = useMiden as ReturnType; + +beforeEach(() => { + useMidenStore.getState().reset(); + vi.clearAllMocks(); +}); + +const fakeRequest = (label: string) => ({ _label: label }); + +describe("useBatch", () => { + describe("initial state", () => { + it("returns the expected initial fields", () => { + mockUseMiden.mockReturnValue({ + client: null, + isReady: false, + sync: vi.fn(), + signerConnected: true, + }); + const { result } = renderHook(() => useBatch()); + expect(result.current.result).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(result.current.stage).toBe("idle"); + expect(result.current.error).toBeNull(); + expect(typeof result.current.batch).toBe("function"); + expect(typeof result.current.reset).toBe("function"); + }); + }); + + describe("batch submission", () => { + it("throws when client is not ready", async () => { + mockUseMiden.mockReturnValue({ + client: null, + isReady: false, + sync: vi.fn(), + signerConnected: true, + }); + const { result } = renderHook(() => useBatch()); + await expect( + result.current.batch({ + items: [{ account: "0xa", request: fakeRequest("r") }], + }) + ).rejects.toThrow("Miden client is not ready"); + }); + + it("rejects an empty items array", async () => { + const mockClient = createMockWebClient(); + mockUseMiden.mockReturnValue({ + client: mockClient, + isReady: true, + sync: vi.fn(), + signerConnected: true, + }); + const { result } = renderHook(() => useBatch()); + await expect(result.current.batch({ items: [] })).rejects.toThrow( + /non-empty array/ + ); + }); + + it("rejects an item missing account or request", async () => { + const mockClient = createMockWebClient(); + mockUseMiden.mockReturnValue({ + client: mockClient, + isReady: true, + sync: vi.fn(), + signerConnected: true, + }); + const { result } = renderHook(() => useBatch()); + await expect( + result.current.batch({ + items: [{ request: fakeRequest("r") } as never], + }) + ).rejects.toThrow(/missing.*account/); + await expect( + result.current.batch({ + items: [{ account: "0xa" } as never], + }) + ).rejects.toThrow(/missing.*request/); + }); + + it("submits via submitNewTransactionBatch and returns blockNumber", async () => { + const mockClient = createMockWebClient({ + submitNewTransactionBatch: vi.fn().mockResolvedValue(42), + }); + const sync = vi.fn().mockResolvedValue(undefined); + mockUseMiden.mockReturnValue({ + client: mockClient, + isReady: true, + sync, + signerConnected: true, + }); + + const { result } = renderHook(() => useBatch()); + let res: { blockNumber: number } | undefined; + await act(async () => { + res = await result.current.batch({ + items: [ + { account: "0xa", request: fakeRequest("r1") }, + { account: "0xb", request: fakeRequest("r2") }, + ], + }); + }); + + expect(res).toEqual({ blockNumber: 42 }); + expect(mockClient.submitNewTransactionBatch).toHaveBeenCalledTimes(1); + const [itemsArg] = mockClient.submitNewTransactionBatch.mock.calls[0]; + expect(itemsArg).toHaveLength(2); + // sync runs before submit + once after for fresh state + expect(sync).toHaveBeenCalledTimes(2); + }); + + it("skips the pre-submit sync when skipSync=true", async () => { + const mockClient = createMockWebClient({ + submitNewTransactionBatch: vi.fn().mockResolvedValue(7), + }); + const sync = vi.fn().mockResolvedValue(undefined); + mockUseMiden.mockReturnValue({ + client: mockClient, + isReady: true, + sync, + signerConnected: true, + }); + + const { result } = renderHook(() => useBatch()); + await act(async () => { + await result.current.batch({ + items: [{ account: "0xa", request: fakeRequest("r") }], + skipSync: true, + }); + }); + + // Only the post-submit sync should have fired. + expect(sync).toHaveBeenCalledTimes(1); + }); + + it("surfaces submission errors and resets stage to idle", async () => { + const err = new Error("submit failed"); + const mockClient = createMockWebClient({ + submitNewTransactionBatch: vi.fn().mockRejectedValue(err), + }); + mockUseMiden.mockReturnValue({ + client: mockClient, + isReady: true, + sync: vi.fn(), + signerConnected: true, + }); + + const { result } = renderHook(() => useBatch()); + await act(async () => { + await expect( + result.current.batch({ + items: [{ account: "0xa", request: fakeRequest("r") }], + }) + ).rejects.toThrow("submit failed"); + }); + expect(result.current.stage).toBe("idle"); + expect(result.current.error?.message).toBe("submit failed"); + }); + }); + + describe("guards", () => { + it("rejects when the signer is not connected", async () => { + const mockClient = createMockWebClient(); + mockUseMiden.mockReturnValue({ + client: mockClient, + isReady: true, + sync: vi.fn(), + signerConnected: false, + }); + const { result } = renderHook(() => useBatch()); + await expect( + result.current.batch({ + items: [{ account: "0xa", request: fakeRequest("r") }], + }) + ).rejects.toThrow(); + // The submit path must not have been reached. + expect(mockClient.submitNewTransactionBatch).not.toHaveBeenCalled(); + }); + + it("rejects a concurrent batch with BATCH_BUSY while the first is in flight", async () => { + // Hold the first submit open via a pending promise so the second call + // sees `isBusyRef.current === true`. + let resolveFirst: ((blockNumber: number) => void) | undefined; + const submitNewTransactionBatch = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }) + ) + .mockResolvedValueOnce(2); + const mockClient = createMockWebClient({ submitNewTransactionBatch }); + mockUseMiden.mockReturnValue({ + client: mockClient, + isReady: true, + sync: vi.fn().mockResolvedValue(undefined), + signerConnected: true, + }); + + const { result } = renderHook(() => useBatch()); + let firstPromise: Promise; + await act(async () => { + firstPromise = result.current.batch({ + items: [{ account: "0xa", request: fakeRequest("first") }], + }); + }); + + await expect( + result.current.batch({ + items: [{ account: "0xb", request: fakeRequest("second") }], + }) + ).rejects.toThrow(/in progress/i); + + // Resolve the first call so the hook unwinds cleanly. + await act(async () => { + resolveFirst!(1); + await firstPromise!; + }); + }); + }); + + describe("reset", () => { + it("clears result, error, and stage", async () => { + const mockClient = createMockWebClient({ + submitNewTransactionBatch: vi.fn().mockResolvedValue(100), + }); + mockUseMiden.mockReturnValue({ + client: mockClient, + isReady: true, + sync: vi.fn(), + signerConnected: true, + }); + const { result } = renderHook(() => useBatch()); + await act(async () => { + await result.current.batch({ + items: [{ account: "0xa", request: fakeRequest("r") }], + }); + }); + expect(result.current.result).toEqual({ blockNumber: 100 }); + act(() => result.current.reset()); + expect(result.current.result).toBeNull(); + expect(result.current.stage).toBe("idle"); + expect(result.current.error).toBeNull(); + }); + }); +}); diff --git a/packages/react-sdk/src/__tests__/mocks/miden-sdk.ts b/packages/react-sdk/src/__tests__/mocks/miden-sdk.ts index df6dae14..20ed6e96 100644 --- a/packages/react-sdk/src/__tests__/mocks/miden-sdk.ts +++ b/packages/react-sdk/src/__tests__/mocks/miden-sdk.ts @@ -281,6 +281,7 @@ export const createMockWebClient = ( .fn() .mockResolvedValue(createMockTransactionRequest()), + submitNewTransactionBatch: vi.fn().mockResolvedValue(100), executeTransaction: vi .fn() .mockResolvedValue(createMockTransactionResult()), @@ -356,6 +357,7 @@ type MockWebClientType = { getPswapLineagesFor: ReturnType; getPswapLineage: ReturnType; buildPswapCancelByOrder: ReturnType; + submitNewTransactionBatch: ReturnType; executeTransaction: ReturnType; proveTransaction: ReturnType; submitProvenTransaction: ReturnType; diff --git a/packages/react-sdk/src/hooks/useBatch.ts b/packages/react-sdk/src/hooks/useBatch.ts new file mode 100644 index 00000000..4e868999 --- /dev/null +++ b/packages/react-sdk/src/hooks/useBatch.ts @@ -0,0 +1,140 @@ +import { useCallback, useRef, useState } from "react"; +import { BatchItem } from "@miden-sdk/miden-sdk"; +import { useMiden } from "../context/MidenProvider"; +import type { BatchOptions, BatchResult, TransactionStage } from "../types"; +import { parseAccountId } from "../utils/accountParsing"; +import { MidenError, assertSignerConnected } from "../utils/errors"; + +export interface UseBatchResult { + /** Submit a multi-transaction batch atomically. */ + batch: (options: BatchOptions) => Promise; + /** The batch result. */ + result: BatchResult | null; + /** Whether the batch submission is in progress. */ + isLoading: boolean; + /** Current stage. `proving` is skipped — proving happens inside the batch primitive. */ + stage: TransactionStage; + /** Error if the batch failed. */ + error: Error | null; + /** Reset the hook state. */ + reset: () => void; +} + +/** + * Hook for atomic multi-transaction batches across one or more local accounts. + * + * Each item pairs a tracked local account with a pre-built `TransactionRequest`. + * The batch is proven and submitted atomically — either every tx lands or none. + * A later tx may consume a note produced by an earlier one (even across accounts); + * push order must respect producer-before-consumer. + * + * @example + * ```tsx + * function BatchButton() { + * const { batch, isLoading } = useBatch(); + * const client = useMidenClient(); + * + * const handleBatch = async () => { + * const reqSend = await client.newSendTransactionRequest( + * alice, bob, token, NoteType.Private, 50n, null, null + * ); + * const reqConsume = await client.newConsumeTransactionRequest([note]); + * const { blockNumber } = await batch({ + * items: [ + * { account: alice, request: reqSend }, + * { account: bob, request: reqConsume }, + * ], + * }); + * console.log("landed in block", blockNumber); + * }; + * + * return ; + * } + * ``` + */ +export function useBatch(): UseBatchResult { + const { client, isReady, sync, signerConnected } = useMiden(); + const isBusyRef = useRef(false); + + const [result, setResult] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [stage, setStage] = useState("idle"); + const [error, setError] = useState(null); + + const batch = useCallback( + async (options: BatchOptions): Promise => { + if (!client || !isReady) { + throw new Error("Miden client is not ready"); + } + assertSignerConnected(signerConnected); + + if (!options.items || options.items.length === 0) { + throw new Error("useBatch: `items` must be a non-empty array"); + } + + if (isBusyRef.current) { + throw new MidenError( + "A batch is already in progress. Await the previous batch before starting another.", + { code: "BATCH_BUSY" } + ); + } + + isBusyRef.current = true; + setIsLoading(true); + setStage("executing"); + setError(null); + + try { + if (!options.skipSync) { + await sync(); + } + + const wasmItems = options.items.map((item, i) => { + if (!item?.account) { + throw new Error(`useBatch: items[${i}] is missing \`account\``); + } + if (!item?.request) { + throw new Error(`useBatch: items[${i}] is missing \`request\``); + } + // Fresh AccountId per item — wasm-bindgen consumes Vec's + // entries, so reusing the same JS proxy across items would invalidate + // earlier ones. + const accountId = parseAccountId(item.account); + return new BatchItem(accountId, item.request as never); + }); + + setStage("submitting"); + const blockNumber = await ( + client as unknown as { + submitNewTransactionBatch: (items: BatchItem[]) => Promise; + } + ).submitNewTransactionBatch(wasmItems); + + const summary: BatchResult = { blockNumber }; + setStage("complete"); + setResult(summary); + + await sync(); + return summary; + } catch (err) { + const e = err instanceof Error ? err : new Error(String(err)); + setError(e); + setStage("idle"); + throw e; + } finally { + setIsLoading(false); + isBusyRef.current = false; + } + }, + [client, isReady, signerConnected, sync] + ); + + const reset = useCallback(() => { + setResult(null); + setIsLoading(false); + setStage("idle"); + setError(null); + }, []); + + return { batch, result, isLoading, stage, error, reset }; +} diff --git a/packages/react-sdk/src/index.ts b/packages/react-sdk/src/index.ts index df94f43a..1ae7033e 100644 --- a/packages/react-sdk/src/index.ts +++ b/packages/react-sdk/src/index.ts @@ -39,6 +39,7 @@ export { useCreateFaucet } from "./hooks/useCreateFaucet"; export { useImportAccount } from "./hooks/useImportAccount"; export { useSend } from "./hooks/useSend"; export { useMultiSend } from "./hooks/useMultiSend"; +export { useBatch } from "./hooks/useBatch"; export { useWaitForCommit } from "./hooks/useWaitForCommit"; export { useWaitForNotes } from "./hooks/useWaitForNotes"; export { useMint } from "./hooks/useMint"; diff --git a/packages/react-sdk/src/types/index.ts b/packages/react-sdk/src/types/index.ts index 4c2b5472..cd085a0e 100644 --- a/packages/react-sdk/src/types/index.ts +++ b/packages/react-sdk/src/types/index.ts @@ -364,6 +364,26 @@ export interface MultiSendOptions { skipSync?: boolean; } +/** A single (account, request) pair for {@link BatchOptions.items}. */ +export interface BatchItemInput { + /** Local account that executes this transaction. */ + account: AccountRef; + /** Pre-built `TransactionRequest`. */ + request: unknown; +} + +export interface BatchOptions { + /** Per-tx `(account, request)` pairs. Must be non-empty. */ + items: BatchItemInput[]; + /** Skip auto-sync before submit. Default: false */ + skipSync?: boolean; +} + +export interface BatchResult { + /** The block number the batch was accepted into. */ + blockNumber: number; +} + export interface WaitForCommitOptions { /** Timeout in milliseconds. Default: 10000 */ timeoutMs?: number; From 9dba5457947dad1c7314438f24db7dc1af395f4f Mon Sep 17 00:00:00 2001 From: Juan Munoz Date: Wed, 17 Jun 2026 10:54:32 -0300 Subject: [PATCH 2/6] chore: fix formatting after merge --- crates/idxdb-store/src/sync/mod.rs | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/crates/idxdb-store/src/sync/mod.rs b/crates/idxdb-store/src/sync/mod.rs index d62b955d..c1ba2c29 100644 --- a/crates/idxdb-store/src/sync/mod.rs +++ b/crates/idxdb-store/src/sync/mod.rs @@ -9,7 +9,6 @@ use miden_client::sync::{ NoteTagRecord, NoteTagSource, PartialBlockchainUpdates, - PublicAccountDelta, PublicAccountUpdate, StateSyncUpdate, }; @@ -372,33 +371,6 @@ impl IdxdbStore { self.undo_account_states(account_commitments).await?; Ok(()) } - - /// Converts a `PublicAccountDelta` (raw incremental RPC payload) into a protocol-level - /// `AccountDelta` by replaying the carried updates against the locally-stored account state. - /// Mirrors `apply_public_account_delta` in `sqlite-store`'s sync path. - async fn public_delta_to_account_delta( - &self, - public_delta: &PublicAccountDelta, - ) -> Result { - let account_id = public_delta.id(); - let local_header = self - .get_account_header(account_id) - .await? - .map(|(header, _)| header) - .ok_or(StoreError::AccountDataNotFound(account_id))?; - let local_storage = self - .get_account_storage( - account_id, - AccountStorageFilter::SlotNames(public_delta.value_slot_names()), - ) - .await?; - let local_vault = self.get_account_vault(account_id).await?; - public_delta - .compute_account_delta(&local_header, &local_storage, &local_vault) - .map_err(|err| { - StoreError::DatabaseError(format!("failed to compute public account delta: {err}")) - }) - } } /// Encodes a [`NoteTagSource`] into the three optional hex-string columns the From 494ab08710a1aec7c2a2ea075b9ede1fa221f770 Mon Sep 17 00:00:00 2001 From: Juan Munoz Date: Wed, 17 Jun 2026 12:01:36 -0300 Subject: [PATCH 3/6] fix(react): wire up batch hook public API Add BATCH_BUSY to MidenErrorCode (thrown by useBatch on concurrent batches) and re-export the batch types (UseBatchResult, BatchOptions, BatchResult, BatchItemInput) from the package entry so they are part of the public surface and pass knip. --- packages/react-sdk/src/index.ts | 4 ++++ packages/react-sdk/src/utils/errors.ts | 1 + 2 files changed, 5 insertions(+) diff --git a/packages/react-sdk/src/index.ts b/packages/react-sdk/src/index.ts index 1ae7033e..73b58c75 100644 --- a/packages/react-sdk/src/index.ts +++ b/packages/react-sdk/src/index.ts @@ -90,6 +90,9 @@ export type { SendResult, MultiSendRecipient, MultiSendOptions, + BatchItemInput, + BatchOptions, + BatchResult, WaitForCommitOptions, WaitForNotesOptions, MintOptions, @@ -176,6 +179,7 @@ export type { UseCreateFaucetResult } from "./hooks/useCreateFaucet"; export type { UseImportAccountResult } from "./hooks/useImportAccount"; export type { UseSendResult } from "./hooks/useSend"; export type { UseMultiSendResult } from "./hooks/useMultiSend"; +export type { UseBatchResult } from "./hooks/useBatch"; export type { UseWaitForCommitResult } from "./hooks/useWaitForCommit"; export type { UseWaitForNotesResult } from "./hooks/useWaitForNotes"; export type { UseMintResult } from "./hooks/useMint"; diff --git a/packages/react-sdk/src/utils/errors.ts b/packages/react-sdk/src/utils/errors.ts index 2b074521..bf08a070 100644 --- a/packages/react-sdk/src/utils/errors.ts +++ b/packages/react-sdk/src/utils/errors.ts @@ -5,6 +5,7 @@ export type MidenErrorCode = | "WASM_SYNC_REQUIRED" | "SEND_BUSY" | "OPERATION_BUSY" + | "BATCH_BUSY" | "UNKNOWN"; export class MidenError extends Error { From 095d425c5934c20b2dbd0feb354ceb886e5ffce2 Mon Sep 17 00:00:00 2001 From: Juan Munoz Date: Mon, 29 Jun 2026 12:29:55 -0300 Subject: [PATCH 4/6] fix: satisfy clippy chunks_exact lint and sync react-sdk peer range Use slice as_chunks::<4>() instead of chunks_exact(4) in note_attachment to clear the clippy chunks_exact_to_as_chunks lint enforced on the pinned nightly. Bump the react-sdk peerDependency and wallet example dependency on @miden-sdk/miden-sdk to ^0.15.4 to match the web-client version bump. --- packages/react-sdk/examples/wallet/package.json | 2 +- packages/react-sdk/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-sdk/examples/wallet/package.json b/packages/react-sdk/examples/wallet/package.json index ba36cab2..e24c0c93 100644 --- a/packages/react-sdk/examples/wallet/package.json +++ b/packages/react-sdk/examples/wallet/package.json @@ -9,7 +9,7 @@ "preview": "vite preview" }, "dependencies": { - "@miden-sdk/miden-sdk": "^0.15.3", + "@miden-sdk/miden-sdk": "^0.15.4", "@miden-sdk/react": "^0.15.3", "react": "^18.2.0", "react-dom": "^18.2.0" diff --git a/packages/react-sdk/package.json b/packages/react-sdk/package.json index 72ba46a5..e3041523 100644 --- a/packages/react-sdk/package.json +++ b/packages/react-sdk/package.json @@ -43,7 +43,7 @@ "test:all": "VITE_CJS_IGNORE_WARNING=1 vitest run && playwright test" }, "peerDependencies": { - "@miden-sdk/miden-sdk": "^0.15.3", + "@miden-sdk/miden-sdk": "^0.15.4", "react": ">=18.0.0" }, "dependencies": { From 669c045f295395cc9d7a6d31864d4b20571c1265 Mon Sep 17 00:00:00 2001 From: Juan Munoz Date: Mon, 29 Jun 2026 16:49:20 -0300 Subject: [PATCH 5/6] fix: align BatchItem node re-export ordering and react peer range after rebase The rebase onto main left BatchItem mis-sorted in node-index.js (with a stray grouping comment) and the react-sdk peer range bumped to ^0.15.4 while web-client is still 0.15.3. Restore alphabetical ordering to match the napi-reexports generator and pin the peer range to ^0.15.3. --- crates/web-client/js/node-index.js | 4 +--- packages/react-sdk/examples/wallet/package.json | 2 +- packages/react-sdk/package.json | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/web-client/js/node-index.js b/crates/web-client/js/node-index.js index f70955d6..554e8f2b 100644 --- a/crates/web-client/js/node-index.js +++ b/crates/web-client/js/node-index.js @@ -177,6 +177,7 @@ export const AuthSecretKey = /* @__PURE__ */ _reexport("AuthSecretKey"); export const BasicFungibleFaucetComponent = /* @__PURE__ */ _reexport( "BasicFungibleFaucetComponent" ); +export const BatchItem = /* @__PURE__ */ _reexport("BatchItem"); export const BlockHeader = /* @__PURE__ */ _reexport("BlockHeader"); export const CodeBuilder = /* @__PURE__ */ _reexport("CodeBuilder"); export const CommittedNote = /* @__PURE__ */ _reexport("CommittedNote"); @@ -274,9 +275,6 @@ export const StorageSlot = /* @__PURE__ */ _reexport("StorageSlot"); export const SyncSummary = /* @__PURE__ */ _reexport("SyncSummary"); export const TokenSymbol = /* @__PURE__ */ _reexport("TokenSymbol"); export const TransactionArgs = /* @__PURE__ */ _reexport("TransactionArgs"); - -// Transaction types -export const BatchItem = /* @__PURE__ */ _reexport("BatchItem"); export const TransactionFilter = /* @__PURE__ */ _reexport("TransactionFilter"); export const TransactionId = /* @__PURE__ */ _reexport("TransactionId"); export const TransactionProver = /* @__PURE__ */ _reexport("TransactionProver"); diff --git a/packages/react-sdk/examples/wallet/package.json b/packages/react-sdk/examples/wallet/package.json index e24c0c93..ba36cab2 100644 --- a/packages/react-sdk/examples/wallet/package.json +++ b/packages/react-sdk/examples/wallet/package.json @@ -9,7 +9,7 @@ "preview": "vite preview" }, "dependencies": { - "@miden-sdk/miden-sdk": "^0.15.4", + "@miden-sdk/miden-sdk": "^0.15.3", "@miden-sdk/react": "^0.15.3", "react": "^18.2.0", "react-dom": "^18.2.0" diff --git a/packages/react-sdk/package.json b/packages/react-sdk/package.json index e3041523..72ba46a5 100644 --- a/packages/react-sdk/package.json +++ b/packages/react-sdk/package.json @@ -43,7 +43,7 @@ "test:all": "VITE_CJS_IGNORE_WARNING=1 vitest run && playwright test" }, "peerDependencies": { - "@miden-sdk/miden-sdk": "^0.15.4", + "@miden-sdk/miden-sdk": "^0.15.3", "react": ">=18.0.0" }, "dependencies": { From de5655cdf4518bd7e48f798ce108f6bfc80fd05b Mon Sep 17 00:00:00 2001 From: Juan Munoz Date: Tue, 30 Jun 2026 16:48:50 -0300 Subject: [PATCH 6/6] chore: address PR comments --- CHANGELOG.md | 2 +- packages/react-sdk/src/__tests__/hooks/useBatch.test.tsx | 4 +++- packages/react-sdk/src/types/index.ts | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67f8f407..91ee3c4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ ### Enhancements * [FEATURE][web,react] AggLayer bridge-out (B2AGG) note support. `client.transactions.bridge({ account, bridgeAccount, token, amount, destinationNetwork, destinationAddress })` bridges a fungible asset out to another network — emitting a single public B2AGG (Bridge-to-AggLayer) note that the bridge account consumes, burning the asset so it can be claimed at the destination Ethereum address on the AggLayer-assigned `destinationNetwork`. The lower-level builders are also exposed: `Note.createB2AggNote(sender, bridgeAccount, assets, destinationNetwork, destinationAddress)` and `client.newB2AggTransactionRequest(...)`. A new `EthAddress` class carries the 20-byte destination address (`EthAddress.fromHex("0x…")` / `EthAddress.fromBytes(bytes)`, with `toHex()` / `toBytes()`). The `@miden-sdk/react` `useBridge()` hook wraps the build-and-submit flow: `bridge({ from, bridgeAccount, assetId, amount, destinationNetwork, destinationAddress })`. Builds on the `miden-agglayer` re-export already present in the bundled `miden-client` — no new dependency. (closes [#173](https://github.com/0xMiden/web-sdk/issues/173)) -* [FEATURE][web] Added `client.transactions.batch({ operations })` to `MidenClient` for atomic multi-tx batches across one or more local accounts. Each operation specifies its executing `account`; a batch may mix operations across any combination of tracked accounts, and a later transaction may consume a note produced by an earlier one (cross-account in-batch note flow supported). Operations are discriminated by `kind` (`"send" | "mint" | "consume" | "swap" | "execute" | "custom"`) and reuse the same options shape as their singular counterparts. Returns `{ blockNumber }`. Companion `submitBatch(items, options?)` takes an array of `{ account, request }` pairs and is the lower-level escape hatch for pre-built `TransactionRequest`s. Wraps the underlying WASM `submitNewTransactionBatch(items: BatchItem[])`, where each `BatchItem` is a `(AccountId, TransactionRequest)` pair built via `new BatchItem(accountId, request)`. ([web-sdk#31](https://github.com/0xMiden/web-sdk/pull/31), client [#2109](https://github.com/0xMiden/miden-client/pull/2109), [#2177](https://github.com/0xMiden/miden-client/pull/2177)) +* [FEATURE][web, react] Added `client.transactions.batch({ operations })` to `MidenClient` for atomic multi-tx batches across one or more local accounts. Each operation specifies its executing `account`; a batch may mix operations across any combination of tracked accounts, and a later transaction may consume a note produced by an earlier one (cross-account in-batch note flow supported). Operations are discriminated by `kind` (`"send" | "mint" | "consume" | "swap" | "execute" | "custom"`) and reuse the same options shape as their singular counterparts. Returns `{ blockNumber }`. Companion `submitBatch(items, options?)` takes an array of `{ account, request }` pairs and is the lower-level escape hatch for pre-built `TransactionRequest`s. Wraps the underlying WASM `submitNewTransactionBatch(items: BatchItem[])`, where each `BatchItem` is a `(AccountId, TransactionRequest)` pair built via `new BatchItem(accountId, request)`. ([web-sdk#31](https://github.com/0xMiden/web-sdk/pull/31), client [#2109](https://github.com/0xMiden/miden-client/pull/2109), [#2177](https://github.com/0xMiden/miden-client/pull/2177)) ## 0.15.3 (2026-06-25) diff --git a/packages/react-sdk/src/__tests__/hooks/useBatch.test.tsx b/packages/react-sdk/src/__tests__/hooks/useBatch.test.tsx index 77870712..b6c23843 100644 --- a/packages/react-sdk/src/__tests__/hooks/useBatch.test.tsx +++ b/packages/react-sdk/src/__tests__/hooks/useBatch.test.tsx @@ -4,6 +4,7 @@ import { useBatch } from "../../hooks/useBatch"; import { useMiden } from "../../context/MidenProvider"; import { useMidenStore } from "../../store/MidenStore"; import { createMockWebClient } from "../mocks/miden-sdk"; +import type { TransactionRequest } from "@miden-sdk/miden-sdk"; vi.mock("../../context/MidenProvider", () => ({ useMiden: vi.fn(), @@ -27,7 +28,8 @@ beforeEach(() => { vi.clearAllMocks(); }); -const fakeRequest = (label: string) => ({ _label: label }); +const fakeRequest = (label: string) => + ({ _label: label }) as unknown as TransactionRequest; describe("useBatch", () => { describe("initial state", () => { diff --git a/packages/react-sdk/src/types/index.ts b/packages/react-sdk/src/types/index.ts index cd085a0e..ed41f52c 100644 --- a/packages/react-sdk/src/types/index.ts +++ b/packages/react-sdk/src/types/index.ts @@ -369,7 +369,7 @@ export interface BatchItemInput { /** Local account that executes this transaction. */ account: AccountRef; /** Pre-built `TransactionRequest`. */ - request: unknown; + request: TransactionRequest; } export interface BatchOptions {