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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 0.17.7 (TBD)

### Enhancements

* [FEATURE][web] Query transaction inclusion without a local store with `RpcClient.syncTransactions(blockFrom, blockTo, accountIds)`. Node.js and browser callers receive `RpcTransactionRecord[]` with `id()`, `accountId()` and `blockNum()` across all account chunks and pages in the inclusive range. Duplicate account IDs are scanned once. Callers maintain their own confirmation cursor and trust the configured RPC node's report of inclusion ([#498](https://github.com/0xMiden/web-sdk/issues/498), [#500](https://github.com/0xMiden/web-sdk/pull/500)).

## 0.17.6 (2026-10-21)

### Fixes
Expand Down
10 changes: 10 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,16 @@ test-web-client-rpc: ## Run native RPC contract tests (no node or local store re
# Dynamic symbols let the Rust test executable link without a Node.js host.
cargo test -p miden-client-web --no-default-features --features nodejs,testing,napi/dyn-symbols --profile $(RUST_TEST_PROFILE) --lib rpc_client::

.PHONY: test-web-client-rpc-nodejs
test-web-client-rpc-nodejs: ## Run native RPC binding tests (no Miden node or local store required)
cargo build -p miden-client-web --no-default-features --features nodejs,testing --release
cd ./crates/web-client && SKIP_WEB_SERVER=1 pnpm exec playwright test rpc_client.node.test.ts --project=nodejs --workers=1

.PHONY: test-web-client-rpc-browser
test-web-client-rpc-browser: build-web-client ## Run browser RPC binding tests (no Miden node required)
pnpm --filter @miden-sdk/miden-sdk exec playwright install chromium webkit
pnpm --filter @miden-sdk/miden-sdk exec playwright test rpc_client.browser.test.ts --project=chromium --project=webkit

.PHONY: integration-test-web-client
SHARD_PARAMETER ?= ""
# Local "clean" run: ensure deps are installed, do a debug WASM build, install
Expand Down
14 changes: 14 additions & 0 deletions crates/web-client/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,20 @@ The block range is inclusive; results are prefix matches, exposed as
nullifier yourself and keep the upper bound at or below the node's chain tip.
This client opens no local store.

To find included transactions, call
`rpc.syncTransactions(blockFrom, blockTo, accountIds)`. It returns every matching
`RpcTransactionRecord` across all pages and account chunks, with `id()`,
`accountId()` and `blockNum()` accessors. Both bounds are inclusive; a reversed
range rejects and an empty account list returns `[]`. Duplicate account IDs are
scanned once. Keep `blockTo` at or below the node's tip. In the browser, the call
consumes the `AccountId` wrappers; recreate them from saved strings for each
array entry, poll or retry.

For confirmation, start at the admission tip returned by submission, match the
exact transaction ID, and advance your own cursor to `blockTo + 1` only after a
successful scan. Inclusion assumes trust in the RPC node. A polling timeout
means unconfirmed, not failed; this method does not poll or track local state.

To submit externally proved work, use `getTransactionEncryptionKey` with your
trusted genesis commitment and trusted block header, then
`submitProvenTx(proof, inputs, key)` or
Expand Down
30 changes: 30 additions & 0 deletions crates/web-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,36 @@ Prefixes are copied before asynchronous work, and duplicates are scanned once
without modifying your array. On Node.js, pass an `ArrayBuffer`-backed
`Uint16Array`; `SharedArrayBuffer` backing is rejected during argument conversion.

To find included transactions, use
`rpc.syncTransactions(blockFrom, blockTo, accountIds)`. It returns
`RpcTransactionRecord[]` with `id(): TransactionId`, `accountId(): AccountId`
and `blockNum(): number`, collecting every page and account chunk in the
inclusive range. Duplicate account IDs are scanned once. An empty account list
returns `[]`; a reversed range rejects.
Keep `blockTo` at or below the node's tip.

```typescript
import { AccountId } from "@miden-sdk/miden-sdk";

const tip = (await rpc.getBlockHeaderByNumber()).blockNum();
const records = await rpc.syncTransactions(
admissionTip, tip, [AccountId.fromHex(accountIdHex)],
);
const included = records.find((record) => record.id().toHex() === txIdHex);
if (included) console.log("Included in block", included.blockNum());
```

Start a confirmation scan at the admission tip returned by submission and match
the exact transaction ID. Keep your own cursor, advancing to `blockTo + 1` after
a successful scan and waiting for the tip to reach that cursor before scanning
again. In the browser, each call consumes its `AccountId` wrappers even on
rejection; construct fresh wrappers from saved strings for each array entry,
poll or retry.
Confirmation assumes trust in the configured RPC node's report of inclusion.
A polling timeout means unconfirmed, not failed. See the
[stateless RPC guide](https://github.com/0xMiden/web-sdk/blob/main/docs/external/src/web-client/library/rpc-client.md#find-included-transactions)
for a polling example.

For submission, supply the proof and its complete execution inputs, plus a
key verified against your trusted network and block header:

Expand Down
3 changes: 3 additions & 0 deletions crates/web-client/js/node-index.js
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,9 @@ export const PswapLineageRecord =
export const PswapLineageState = /* @__PURE__ */ _reexport("PswapLineageState");
export const PublicKey = /* @__PURE__ */ _reexport("PublicKey");
export const RpcClient = /* @__PURE__ */ _reexport("RpcClient");
export const RpcTransactionRecord = /* @__PURE__ */ _reexport(
"RpcTransactionRecord"
);
export const Rpo256 = /* @__PURE__ */ _reexport("Rpo256");
export const Signature = /* @__PURE__ */ _reexport("Signature");
export const SigningInputs = /* @__PURE__ */ _reexport("SigningInputs");
Expand Down
1 change: 1 addition & 0 deletions crates/web-client/js/types/docs-entry.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export {
OutputNoteRecord,
OutputNotes,
RpcClient,
RpcTransactionRecord,
StorageMapPatchEntry,
StorageMapSlotPatch,
StoragePatchOperation,
Expand Down
31 changes: 31 additions & 0 deletions crates/web-client/skills/web-client-usage/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,37 @@ Prefixes are copied before asynchronous work and duplicates are scanned once;
the caller's array is unchanged. Node.js requires an `ArrayBuffer`-backed
`Uint16Array` and rejects `SharedArrayBuffer` backing during argument conversion.

`rpc.syncTransactions(blockFrom, blockTo, accountIds: AccountId[])` returns
`RpcTransactionRecord[]` for an inclusive range. Each record exposes
`id(): TransactionId`, `accountId(): AccountId` and `blockNum(): number`.
This is a node result, distinct from a locally tracked `TransactionRecord`.
The SDK collects every page for every account chunk and verifies that returned
accounts were requested. Duplicate account IDs are scanned once. An empty account
list returns `[]` without RPC calls; a reversed range rejects. Keep `blockTo` at
or below the node's chain tip. In the browser, create a fresh `AccountId` wrapper
for each array entry because the call consumes them.

```typescript
const blockTo = (await rpc.getBlockHeaderByNumber()).blockNum();
if (cursor <= blockTo) {
const records = await rpc.syncTransactions(
cursor, blockTo, [AccountId.fromHex(accountIdHex)],
);
const included = records.find((record) => record.id().toHex() === txIdHex);
if (included) console.log("Included in block", included.blockNum());
cursor = blockTo + 1;
}
```

Import `AccountId` from `@miden-sdk/miden-sdk`. Initialize `cursor` to the
admission tip returned by submission and retain it between polls. Advance it
only after a successful scan. In the browser, the call consumes the account ID
wrappers even on rejection, so construct fresh wrappers from saved strings on
each poll or retry. Match the exact transaction ID, not merely any transaction
for the account. Confirmation assumes trust in the configured RPC node; these
records do not independently prove canonical inclusion. A polling timeout means
unconfirmed, not failed. `syncTransactions` does not poll or update local state.

For externally proved transactions:

```typescript
Expand Down
1 change: 1 addition & 0 deletions crates/web-client/src/rpc_client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ use crate::platform::JsErr;
mod note;
mod nullifier;
mod submission;
mod transaction;

/// RPC Client for interacting with Miden nodes directly.
#[js_export]
Expand Down
97 changes: 97 additions & 0 deletions crates/web-client/src/rpc_client/transaction.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
use js_export_macro::js_export;
use miden_client::rpc::domain::transaction::TransactionRecord as NativeTransactionRecord;

use super::RpcClient;
use crate::js_error_with_context;
use crate::models::account_id::AccountId;
use crate::models::transaction_id::TransactionId;
use crate::platform::{JsErr, from_str_err};

/// A transaction reported as included by `RpcClient.syncTransactions`.
///
/// Unlike the locally tracked `TransactionRecord`, this record comes directly from the node
/// and has no local status or submission height. Inclusion assumes trust in the RPC node;
/// this response does not independently prove inclusion in the canonical chain.
#[js_export]
pub struct RpcTransactionRecord(NativeTransactionRecord);

#[js_export]
impl RpcTransactionRecord {
/// Returns the transaction ID. Compare it with the exact ID of the submitted transaction.
pub fn id(&self) -> TransactionId {
self.0.transaction_header.id().into()
}

/// Returns the account against which the transaction executed.
#[js_export(js_name = "accountId")]
pub fn account_id(&self) -> AccountId {
self.0.transaction_header.account_id().into()
}

/// Returns the block in which the node reports the transaction was included.
#[js_export(js_name = "blockNum")]
pub fn block_num(&self) -> u32 {
self.0.block_num.as_u32()
}
}

#[js_export]
impl RpcClient {
/// Fetches transactions for the requested accounts within an inclusive block range.
///
/// Uses the Rust RPC client's account-list chunking, pagination and account-ID verification,
/// returning all matching records across every page. Duplicate account IDs are scanned once.
/// An empty account list returns an empty array without RPC calls. No local store or tracked
/// accounts are required or updated.
///
/// The caller must keep `block_to` at or below the node's chain tip, available through
/// `(await rpc.getBlockHeaderByNumber()).blockNum()`. A reversed range rejects. Callers retain
/// a polling cursor: after successfully scanning a range, start the next scan at `block_to
/// + 1`. To confirm a submission, scan from its admission tip and match the exact
/// transaction ID. Inclusion relies on trusting the configured RPC node; a polling timeout
/// means unconfirmed, not failed. This method does not wait for future blocks or track
/// confirmation itself.
///
/// In the browser, this call consumes the `AccountId` wrappers, even if it rejects. Construct
/// fresh wrappers from saved account ID strings for each array entry, poll or retry.
///
/// @param block_from - First block to scan, inclusive.
/// @param block_to - Last block to scan, inclusive; must be at least `block_from` and no higher
/// than the node's chain tip.
/// @param account_ids - Accounts whose transactions to fetch.
/// @returns All matching `RpcTransactionRecord` objects, each with `id()`, `accountId()` and
/// `blockNum()` accessors.
#[allow(clippy::doc_markdown)]
#[js_export(js_name = "syncTransactions")]
pub async fn sync_transactions(
&self,
block_from: u32,
block_to: u32,
account_ids: Vec<AccountId>,
) -> Result<Vec<RpcTransactionRecord>, JsErr> {
if block_from > block_to {
return Err(from_str_err("block_from must be less than or equal to block_to"));
}
let mut account_ids = account_ids.into_iter().map(Into::into).collect::<Vec<_>>();
account_ids.sort_unstable();
account_ids.dedup();

self.inner
.sync_transactions(block_from.into(), block_to.into(), account_ids)
.await
.map_err(|err| js_error_with_context(err, "failed to sync transactions"))
.and_then(|records| {
if let Some(record) = records
.iter()
.find(|record| !(block_from..=block_to).contains(&record.block_num.as_u32()))
{
return Err(from_str_err(&format!(
"failed to sync transactions: node returned transaction at block {} outside requested range {block_from}..={block_to}",
record.block_num.as_u32()
)));
}

Ok(records.into_iter().map(RpcTransactionRecord).collect())
})
}
}
75 changes: 75 additions & 0 deletions crates/web-client/test/rpc-client-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,78 @@ export function frame(body: Buffer, type = 0): Buffer {
header.writeUInt32BE(body.length, 1);
return Buffer.concat([header, body]);
}

export const transactionAccounts = [
"0xc6da5c4573a3731151114a1aa8fe8e",
"0x6ccbb3931bbb21910bb3018972d56d",
];

// Poseidon2 transaction IDs for zero initial state, final state [1|2, 0, 0, 0],
// and empty input/output notes. The Rust decoder verifies these header IDs.
export const transactionIds = [
"0x1e7e9f4ff0f6f939f60a2f6d10a4fc031a64def2fc66cb968acfc004b1e2d433",
"0x79e4476648bc2da19e8bb298ea4424ebb2d5c5bcaf1e7ab20a535f46201dd37b",
];

export function transactionAccount(hex: string): Buffer {
const felt = (value: bigint) => {
const bytes = Buffer.alloc(9);
bytes[0] = 0x09; // Felt.value: fixed64 field 1.
bytes.writeBigUInt64LE(value, 1);
return bytes;
};
return bytesField(
1,
Buffer.concat([
bytesField(1, felt(BigInt(`0x${hex.slice(18)}00`))),
bytesField(2, felt(BigInt(hex.slice(0, 18)))),
])
);
}

export function transactionRequest(body: Buffer) {
expect(body[0]).toBe(0);
expect(body.readUInt32BE(1)).toBe(body.length - 5);
const request = fields(body.subarray(5));
const range = fields(request.get(1)![0] as Buffer);
return {
blockFrom: range.get(1)?.[0] ?? 0,
blockTo: range.get(2)?.[0] ?? 0,
accountIds: (request.get(2) ?? []) as Buffer[],
};
}

export function transactionPage(
blockNum: number,
records: { accountId: string; finalState: 1 | 2; blockNum: number }[] = []
): Buffer {
return Buffer.concat([
bytesField(
1,
Buffer.concat([fixed32Field(1, 12), fixed32Field(2, blockNum)])
),
...records.map((record) => {
const finalState = Buffer.alloc(32);
finalState.writeBigUInt64LE(BigInt(record.finalState));
const header = Buffer.concat([
bytesField(
1,
bytesField(
1,
bytesField(
1,
Buffer.from(transactionIds[record.finalState - 1].slice(2), "hex")
)
)
),
bytesField(2, transactionAccount(record.accountId)),
bytesField(3, bytesField(1, Buffer.alloc(32))),
bytesField(4, bytesField(1, finalState)),
]);
return bytesField(
2,
Buffer.concat([fixed32Field(1, record.blockNum), bytesField(2, header)])
);
}),
]);
}
Loading
Loading