diff --git a/.claude/skills/wasm-bridge/SKILL.md b/.claude/skills/wasm-bridge/SKILL.md index 0bedb294..0ef21f8d 100644 --- a/.claude/skills/wasm-bridge/SKILL.md +++ b/.claude/skills/wasm-bridge/SKILL.md @@ -516,10 +516,10 @@ MidenClient._getWasmOrThrow = getWasmOrThrow; There is **no** `safe-arrays.js` module. The wasm-bindgen array wrappers are generated by the `declare_js_miden_arrays!` macro (defined in `crates/web-client/src/miden_array.rs:41`, invoked in -`crates/web-client/src/models/mod.rs:134-148`), which produces **thirteen** +`crates/web-client/src/models/mod.rs:133-146`), which produces **twelve** types, in this order: `AccountArray`, `AccountIdArray`, `ForeignAccountArray`, `NoteRecipientArray`, `NoteArray`, `OutputNoteArray`, `StorageSlotArray`, -`TransactionScriptInputPairArray`, `FeltArray`, `AccountInputsArray`, +`TransactionScriptInputPairArray`, `FeltArray`, `NoteAndArgsArray`, `NoteDetailsAndTagArray`, `NoteIdAndArgsArray`. Count them from the macro invocation rather than from any list, including this one - the set grows. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8a6644db..d79b8a88 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,8 +24,8 @@ env: # to in Cargo.lock: that ref's own Cargo.lock pins the node rev the client is # built against. A commit sha (not the branch name) keeps cache keys stable. # Re-pin whenever Cargo.lock moves, and to the release tag once one ships. - # v0.16.0 - MIDEN_CLIENT_REF: 5478eeb3baf84a24d129b292fbc5ba558740bd46 + # v0.17.0-rc.1 + MIDEN_CLIENT_REF: 2fb20aca0869034dd26aa35473dbfb14fa017083 jobs: # Pre-flight: detect whether any non-docs files changed. See build.yml's @@ -991,6 +991,18 @@ jobs: # tests draw fee balances from the genesis funder wallets. MIDEN_VERIFICATION_BASE_FEE: "0" run: ./scripts/start-test-node.sh --background + # Since 0.17 the fee asset lives in the protocol configuration rather than + # the block header, so a client is told which faucet issues it instead of + # reading it off a block. Each genesis mints a fresh one, so it is read + # from the bootstrap log rather than pinned. + - name: Export the node's native fee faucet + working-directory: miden-client + run: | + ID=$(sed -n 's/^Native faucet account id: //p' \ + target/test-node/data/logs/bootstrap.log | tail -1) + [ -n "$ID" ] || { echo "::error::native faucet id not found in bootstrap.log"; exit 1; } + echo "TEST_MIDEN_FEE_FAUCET_ID=$ID" >> "$GITHUB_ENV" + echo "native fee faucet: $ID" - name: Build napi binary run: cargo build -p miden-client-web --no-default-features --features nodejs,testing --release - name: Stage napi binary as `miden_client_web.node` @@ -1092,6 +1104,18 @@ jobs: # tests draw fee balances from the genesis funder wallets. MIDEN_VERIFICATION_BASE_FEE: "0" run: ./scripts/start-test-node.sh --background + # Since 0.17 the fee asset lives in the protocol configuration rather than + # the block header, so a client is told which faucet issues it instead of + # reading it off a block. Each genesis mints a fresh one, so it is read + # from the bootstrap log rather than pinned. + - name: Export the node's native fee faucet + working-directory: miden-client + run: | + ID=$(sed -n 's/^Native faucet account id: //p' \ + target/test-node/data/logs/bootstrap.log | tail -1) + [ -n "$ID" ] || { echo "::error::native faucet id not found in bootstrap.log"; exit 1; } + echo "TEST_MIDEN_FEE_FAUCET_ID=$ID" >> "$GITHUB_ENV" + echo "native fee faucet: $ID" # Install Playwright separately from the test run. Inlining the install # into the test:ci script (yarn-style chained `&&`) breaks pnpm arg # forwarding: pnpm appends `-- --project=X` to the LAST command, but @@ -1101,6 +1125,21 @@ jobs: run: pnpm --filter @miden-sdk/miden-sdk run test:install:ci - name: Run web client tests run: pnpm --filter @miden-sdk/miden-sdk run test:ci --project=${{ matrix.project }} + # A network transaction that the builder declines is not an error anywhere the test can + # see: the note simply stays unconsumed. Its own log is the only place that says why. + - name: Dump node logs on failure + if: failure() + run: | + # Locate the logs rather than assuming a path: a previous attempt + # guessed one and reported three missing files, which says nothing. + echo "::group::log files found" + find . -path ./node_modules -prune -o -name '*.log' -path '*test-node*' -print 2>/dev/null | head -40 + echo "::endgroup::" + find . -path ./node_modules -prune -o -name '*.log' -path '*test-node*' -print 2>/dev/null | while read -r f; do + echo "::group::$f (last 200 lines)" + tail -n 200 "$f" + echo "::endgroup::" + done - name: Upload Playwright JSON report if: always() uses: actions/upload-artifact@v4 @@ -1179,6 +1218,18 @@ jobs: # tests draw fee balances from the genesis funder wallets. MIDEN_VERIFICATION_BASE_FEE: "0" run: ./scripts/start-test-node.sh --background + # Since 0.17 the fee asset lives in the protocol configuration rather than + # the block header, so a client is told which faucet issues it instead of + # reading it off a block. Each genesis mints a fresh one, so it is read + # from the bootstrap log rather than pinned. + - name: Export the node's native fee faucet + working-directory: miden-client + run: | + ID=$(sed -n 's/^Native faucet account id: //p' \ + target/test-node/data/logs/bootstrap.log | tail -1) + [ -n "$ID" ] || { echo "::error::native faucet id not found in bootstrap.log"; exit 1; } + echo "TEST_MIDEN_FEE_FAUCET_ID=$ID" >> "$GITHUB_ENV" + echo "native fee faucet: $ID" # See integration-tests-web-client for rationale on splitting the # Playwright install from the test invocation. - name: Install Playwright browsers diff --git a/CHANGELOG.md b/CHANGELOG.md index 046a46a2..71527a2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ ### Changes +* [CHANGE][web] Upgraded `miden-client` to 0.17.0-rc.1 (from 0.16.1), which adopts protocol 0.17.0-rc.5 and VM 0.33. Requires a node on the matching protocol - a 0.16 node rejects a 0.17 client at the accept header, before any request is served - and a new client database. The entries below are the parts of that upgrade a JS consumer has to act on ([client 0.17.0-rc.1](https://github.com/0xMiden/rust-sdk/releases/tag/v0.17.0-rc.1)) ([#406](https://github.com/0xMiden/web-sdk/pull/406)). +* [BREAKING][web] A client now needs a protocol configuration, and `ClientOptions.feeFaucetId` is how it gets one. 0.17 moved the fee asset out of the block header into the protocol configuration, which execution and note screening both resolve by the commitment in the transaction's reference block; a client holding none cannot execute at all. The node does not serve the configuration over RPC yet, so the SDK builds it from the chain's fee faucet: pass `feeFaucetId` (a bech32 address or a hex account ID) for any network the SDK knows no fee faucet for, and creating the client fails with a message naming the option rather than a store error on the first transaction. A mock client is unaffected - its configuration comes from the mock chain itself ([#406](https://github.com/0xMiden/web-sdk/pull/406)). +* [BREAKING][web] `BlockHeader.feeFaucetId()` is removed, replaced by `client.feeFaucetId()`. The header no longer carries the fee asset, so the fee faucet can no longer be discovered by reading any block: the client reports the one its registered protocol configuration names, which is the `feeFaucetId` it was created with. `BlockHeader.verificationBaseFee()` is unchanged - the base fee is still a header field ([#406](https://github.com/0xMiden/web-sdk/pull/406)). +* [BREAKING][web] `BlockHeader.txKernelCommitment()` is removed, replaced by `BlockHeader.protocolConfigCommitment()`. The header committed to the transaction kernel individually before 0.17; it now commits to the protocol configuration, which carries the transaction, batch and block kernels along with the fee asset ([#406](https://github.com/0xMiden/web-sdk/pull/406)). +* [BREAKING][web] `ForeignAccount.prefetched()`, the `AccountInputs` and `AccountInputsArray` types, `client.transactions.foreignAccountInputs()` and `WebClient.getForeignAccountInputs()` are removed, following the client dropping `ForeignAccount::Prefetched` and making `AccountInputs` private. 0.17 resolves a foreign account's vault and storage-map entries during execution, as per-asset and per-key witnesses against the reference block, instead of requiring the whole state up front - which is what prefetching existed to avoid. Declare the account with `ForeignAccount.public(...)` or `ForeignAccount.private(...)` and execute against a recent reference block; a transaction pinned to a block the node no longer serves account state for can no longer be made to execute by prefetching ([#406](https://github.com/0xMiden/web-sdk/pull/406)). +* [BREAKING][web] `AccountVaultDelta.fungible()` and the `FungibleAssetDelta` and `FungibleAssetDeltaItem` types are removed, following the upstream type. A vault delta is now a set of whole assets added or removed rather than a map of signed per-faucet balance changes, so there is no aggregate to hand out. `addedFungibleAssets()` and `removedFungibleAssets()` are unchanged and remain the way to read it; `numAssets()` is new and reports how many assets the delta changes, added and removed together ([#406](https://github.com/0xMiden/web-sdk/pull/406)). +* [FIX][web] A request for a multisig account carries that component's own auth args, at any base fee. 0.17's multisig auth procedure reads three words - the block its summary binds with the approval expiration, the salt, and the fee conversion info - and resolves them unconditionally, skipping only the fee note itself when the base fee is zero. `miden-client` commits the two-word fee pair a fixed-salt component reads, so a multisig was handed a preimage one word short and aborted piping it (`advice stack read failed`); on a fee-free chain it was handed no auth args at all. The SDK now builds the three-word shape and sets it as the request's auth argument, which `miden-client` leaves alone. Every summary-producing path was affected, including `executeForSummary` and chain-anchored co-signing ([#406](https://github.com/0xMiden/web-sdk/pull/406)). +* [FEATURE][web] `client.feeAwareTransactionRequestBuilder(account, options?)` takes the three multisig values the approvers sign over. `approvalExpirationDelta` stops the signatures authorizing the transaction that many blocks after the block the summary binds, so a proposal that is never executed cannot be executed much later instead. `feeConversionSalt` and `boundBlockNum` are what a co-signer needs to *reproduce* a proposal rather than receive one: left out, the salt is drawn per build and the block is the local sync height, so two parties who disagree on either can never derive the same summary. A co-signer holding the proposer's serialized request needs neither, since it carries the auth argument and its advice-map preimage. Every field is optional, defaulted, and ignored for an account that is not a multisig. Do not call `withFeeConversionSalt` or `withAuthArg` on the returned builder for a multisig: the two setters clear each other, so either discards the auth args it already carries ([#406](https://github.com/0xMiden/web-sdk/pull/406)). +* [CHANGE][web] A network account can no longer be deployed by an empty transaction. 0.17's network auth component asserts the transaction had an effect before it pays the fee - an input note consumed, an output note created, or the account state changed - so the scriptless nonce bump that used to commit the account on-chain now aborts with `network account transactions must have an effect before fee payment`. Deploy by consuming a note whose script the account allowlists ([#406](https://github.com/0xMiden/web-sdk/pull/406)). +* [CHANGE][web] `client.transactions.createNetworkNote` declares the target network account as a foreign account, and so does `useCreateNetworkNote`. Since 0.17 the kernel prices a note carrying a `NetworkAccountTarget` attachment by calling `estimate_note_fee` on the target, so the emitting transaction reads foreign state: declaring it pins that state at the reference block rather than leaving the client to resolve the account lazily, which it can only do for a public account it can reach. `buildNetworkNote` only builds the note, so a caller submitting it themselves owns the declaration; its documentation now says so ([#406](https://github.com/0xMiden/web-sdk/pull/406)). +* [CHANGE][web] Every network account built from `AccountComponent.createNetworkAuthComponents` now allowlists the P2ID note script, and the factory prices it at zero along with the other roots `AuthNetworkAccount` adds by default. 0.17 added P2ID to that default list as a stopgap for deployment - upstream marks it "replace by the DEPLOY note script" - so such an account auto-consumes P2ID notes whether or not the caller named that root. The factory derives the zero-priced set from upstream rather than restating it, because an allowlisted root with no fee entry aborts fee estimation: before this, a caller who did not name P2ID got an account that would auto-consume those notes and then fail to price them ([#406](https://github.com/0xMiden/web-sdk/pull/406)). +* [CHANGE][web] `feeAwareTransactionRequestBuilder`'s `feeConversionSalt` consumes the `Word` passed to it, as every by-value handle across the WASM boundary does. Build a fresh one per call: a spent handle is not rejected, it arrives as if no salt were given and one is drawn, which is exactly the divergence pinning the salt prevents ([#406](https://github.com/0xMiden/web-sdk/pull/406)). +* [CHANGE][web] Emitting a note that carries a `NetworkAccountTarget` caps the transaction at 20 blocks. Pricing it calls `estimate_note_fee` on the target, and that procedure applies the standards' default expiration delta - about a minute at a three-second block interval - to bound how stale the foreign state it read may be. A transaction expiration can only be lowered, never raised, so the emitter cannot widen it: the transaction has to be included within 20 blocks of its reference block or it is rejected as expired, and a slow prover is enough to miss that on a busy chain. Re-execute against a fresh reference block and submit again ([#406](https://github.com/0xMiden/web-sdk/pull/406)). +* [CHANGE][web] The fee faucet passed to `AccountComponent.createNetworkAuthComponents` must be the chain's own, read with `client.feeFaucetId()`. A 0.17 node's network-transaction builder compares a network account's fee asset with the chain's protocol configuration and refuses to execute for any account where they differ. Nothing reaches the client: the notes sent to such an account are simply never consumed, and only the node's log says why (`network account fee asset does not match the protocol configuration`). The fee asset is fixed when the account is built, so an account built with another faucet has to be rebuilt ([#406](https://github.com/0xMiden/web-sdk/pull/406)). +* [CHANGE][react] `MidenConfig.feeFaucetId` hands the chain's fee faucet to the client `MidenProvider` builds, for the same reason: without it the provider's client can neither execute nor screen notes on a network the SDK knows no fee faucet for ([#406](https://github.com/0xMiden/web-sdk/pull/406)). +* [CHANGE][repo] `tools/strip-masp-debug`, which removes the embedded MAST packages' debug info from the published WASM, now names the VM version the client embeds (0.33). It requested `0.29`, which resolved a second, older copy of the package format: against a 0.17 artifact it recognised no packages, exited non-zero, and the build skipped `wasm-opt` altogether - leaving an ST artifact of 118.5 MiB against the 25 MiB the size gate allows. Workspace membership does not pin the tool to what the binary contains; the version requirement does, so it moves with `miden-client` ([#406](https://github.com/0xMiden/web-sdk/pull/406)). +* [CHANGE][web] `BlockHeader.version()` reports the protocol's `u8` rather than a widened `u32`. Both reach JS as a `number`, so only a Rust caller of the crate sees a difference ([#406](https://github.com/0xMiden/web-sdk/pull/406)). * [BREAKING][web] `ForeignAccount.account_id()` and `ForeignAccount.storage_slot_requirements()` are now `accountId()` and `storageSlotRequirements()` on the WASM build. They had no `js_name`, so the WASM build exposed them in snake_case while napi camelCased them by default, leaving the same two accessors with different names depending on which build a consumer loaded. The camelCase spelling matches what Node already exposed and the rest of the JS surface, so only WASM callers change. * [CHANGE][adapter] `@miden-sdk/miden-wallet-adapter-{base,miden,reactui}` now declare a `files` array and ship only what a consumer resolves. All three had neither `files` nor `.npmignore`, so npm packed the whole directory: TypeScript sources, `__tests__`, `tsconfig.json`, `vitest.config.ts`, the compiled tests and vitest config under `dist/`, and the generated typedoc `docs/` tree. `base` went from 131 files / 273 KB to 24 / 56 KB, `miden` from 35 / 230 KB to 11 / 44 KB, `reactui` from 77 / 218 KB to 46 / 73 KB. Every documented entry point still resolves, including both `@miden-sdk/miden-wallet-adapter-reactui/styles.css` and `.../reactui/dist/styles.css`. None of the three declares an `exports` map, so a deep import into their sources was legal and now stops resolving; import from the package root, or from `dist/` for the stylesheet ([#394](https://github.com/0xMiden/web-sdk/pull/394)). diff --git a/Cargo.lock b/Cargo.lock index 11847bf9..1387c260 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2270,21 +2270,22 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "miden-ace-codegen" -version = "0.29.4" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00ff2a44c7f7dc497ac56c74dca5b3465b4b46be066fa88c2c58a8eb1bcb2dc8" +checksum = "5a92cf9215a1e813831a3048b7424246506d79009471e70209911be03383bd6f" dependencies = [ "miden-constraint-compiler", "miden-core", "miden-crypto", + "miden-field", "thiserror", ] [[package]] name = "miden-agglayer" -version = "0.16.1" +version = "0.17.0-rc.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9340d981c2aaefded2a28ca66f10169dd4170c912538d33fe8690ea549e468e" +checksum = "741c7243663a2cd685b3c4e60b4ddadc43908cb8bc7b4cc3d30e142124bfbdf2" dependencies = [ "alloy-sol-types", "fs-err", @@ -2305,15 +2306,16 @@ dependencies = [ [[package]] name = "miden-air" -version = "0.29.4" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84b6d3f3336c8a2da5cd0924c42dd1f339e6c3d5adf3221bc1c005b444fd0bf0" +checksum = "24605e788e49663cbfc37e2f938d08f6a41a0846975ed2baebd5350b980e8817" dependencies = [ "miden-ace-codegen", "miden-core", "miden-crypto", "miden-utils-indexing", "p3-field", + "p3-security", "proptest", "thiserror", "tracing", @@ -2321,9 +2323,9 @@ dependencies = [ [[package]] name = "miden-assembly" -version = "0.29.4" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "644b31bdda941328f9ff2088382b7de41569c1cc83d6090f63e43d261e9cd262" +checksum = "d139e3af0c4df26caaf0be15d2b43cce479d3cd1efd3f9b1c86aee608584b75a" dependencies = [ "env_logger", "log", @@ -2339,9 +2341,9 @@ dependencies = [ [[package]] name = "miden-assembly-syntax" -version = "0.29.4" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cad0d5174833b17b498d541d1ef62d73929a143ea56ea5eba719339c15cd0968" +checksum = "fc668b749d05fdc7d30db6b4a51b13cd6f585e6cbdc0cad73c788a2645c21a5b" dependencies = [ "env_logger", "log", @@ -2362,31 +2364,32 @@ dependencies = [ [[package]] name = "miden-assembly-syntax-cst" -version = "0.29.4" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff301e56d9201821a4458564ed19ae9b95c7a219662a422e47bb61d17e0fa7b1" +checksum = "7cd90acb16b64741b9f52864a4826e98fc2be9c2a9eb6a3649b3ef99fc113ebc" dependencies = [ "miden-debug-types", "miden-rowan", "miden-utils-diagnostics", - "thiserror", ] [[package]] name = "miden-block-prover" -version = "0.16.1" +version = "0.17.0-rc.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e34859c5f2005e95cfc5ba97ae0dffec15bb44460fc23262532e5e2aee49155" +checksum = "82e054c08131995b76c5eb9c33a1fe9c4c352db3e6c5315f4409343cb1b0dded" dependencies = [ + "miden-processor", "miden-protocol", + "miden-prover", "thiserror", ] [[package]] name = "miden-client" -version = "0.16.1" +version = "0.17.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f62f7b7382bcb636a9651f678d84ed675adcb428276bd80688b34651f4fa9f1" +checksum = "51a4336958775c89f1ed7ba6f1b88d984f6c80173a09fcc661889c631fab163e" dependencies = [ "anyhow", "async-trait", @@ -2394,11 +2397,11 @@ dependencies = [ "futures", "getrandom 0.3.4", "gloo-timers", - "hex", "miden-agglayer", "miden-assembly-syntax", "miden-node-proto-build", "miden-note-transport-proto-build", + "miden-objects", "miden-processor", "miden-protocol", "miden-standards", @@ -2425,9 +2428,9 @@ dependencies = [ [[package]] name = "miden-client-sqlite-store" -version = "0.16.1" +version = "0.17.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e14168a329fae4395e481fb65b3605769f57e08181ee6c053c171e0edcad760" +checksum = "12dfb9674f988c511dff9fe868f58188a7c0fc4c15bd8790763adf46b1cc21a6" dependencies = [ "anyhow", "async-trait", @@ -2455,6 +2458,7 @@ dependencies = [ "miden-client", "miden-client-sqlite-store", "miden-idxdb-store", + "miden-protocol", "napi", "napi-build", "napi-derive", @@ -2475,9 +2479,9 @@ dependencies = [ [[package]] name = "miden-constraint-compiler" -version = "0.29.4" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01ef51ec02f0899c5fc6aa4af11eb050094d98e5e6bf8767a4da555d210f2de6" +checksum = "27008009c45f044afde1b6e623eb9904d8d89b1f23b6e20740274a9b6c862ec9" dependencies = [ "miden-core", "miden-crypto", @@ -2485,28 +2489,28 @@ dependencies = [ [[package]] name = "miden-core" -version = "0.29.4" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b27f9f91988c5e74b50b543c4e6d37ec729eabcf70db63cf752dedd780f8a90" +checksum = "668e8b74719dee5e4315ac496d97d06200eb93b522508fbe162437ebee546ec8" dependencies = [ "derive_more", "log", "miden-crypto", "miden-debug-types", "miden-formatting", + "miden-serde-utils", "miden-utils-core-derive", "miden-utils-indexing", "miden-utils-sync", "proptest", - "serde", "thiserror", ] [[package]] name = "miden-core-lib" -version = "0.29.4" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14df9652fc4963f20d11df9ceb576ccb7831b5dc72fabec5c1b602f51e57909c" +checksum = "ab8eda9033e4372c98bfea9ac4a14502767c099fdab3bcc1b1fc6d5a09f0049a" dependencies = [ "env_logger", "fs-err", @@ -2525,19 +2529,20 @@ dependencies = [ [[package]] name = "miden-core-lib-codegen" -version = "0.29.4" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aad0b8badcf1636150cac0d74aabadd395ba7d570227eb0923ff14f02a278fff" +checksum = "ee25d1c87b4ace562a28de3913e620ff33f91e060bc2216f1d210df8cd8d8113" dependencies = [ "miden-core", "miden-precompiles", + "tempfile", ] [[package]] name = "miden-crypto" -version = "0.29.4" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c917b0342d911ae4b7a549bb3ca791c093dac4770f88b02e23b43f3fa8179f5" +checksum = "a5109b16acff5262c379f1d7359993859111051765243179d9e8dff81b9a7d75" dependencies = [ "blake3", "cc", @@ -2577,9 +2582,9 @@ dependencies = [ [[package]] name = "miden-crypto-derive" -version = "0.29.4" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7c3165dfd7fd6f587ea5731efd1cc8083ca55c23d2d9b3000f83a3948486044" +checksum = "1d543d0ec3e2206bf8e332a2957f04e9c33145dd445d7e3ddee3135d8d9573dd" dependencies = [ "quote", "syn 2.0.119", @@ -2587,9 +2592,9 @@ dependencies = [ [[package]] name = "miden-debug-types" -version = "0.29.4" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c9f3f8e4a8f54a36fbf00330ac8bd1947627c1786cd49b94a08b741590ed173" +checksum = "4fb9eb2a101717e82ae8e995fa882c798e93541179fee46c079cf64290af7877" dependencies = [ "memchr", "miden-crypto", @@ -2607,9 +2612,9 @@ dependencies = [ [[package]] name = "miden-field" -version = "0.29.4" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58cf9de55b88ec86ad272ff4224d76b3e0cbda49e242e78776b0037ba9b0e857" +checksum = "236ab8e249c6368960ad1c9d92b698f6f7bb7e54b32da750269ed506913657ea" dependencies = [ "miden-serde-utils", "num-bigint 0.5.1", @@ -2652,9 +2657,9 @@ dependencies = [ [[package]] name = "miden-lifted-air" -version = "0.29.4" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f67e06d47e246db853a9f3e52ec87fa33a8ffc0fe5be0dd99288b2439312f206" +checksum = "9d48ba1a319d64a7d5c692c50346fb1f1791da090893e692fa6bafb501ffc421" dependencies = [ "p3-air", "p3-challenger", @@ -2666,9 +2671,9 @@ dependencies = [ [[package]] name = "miden-lifted-stark" -version = "0.29.4" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5ee320597c7712698d06811d9144b0e4f2275a21224ef0238652c947b01198b" +checksum = "28826676d5b4eeb3af7d27795fb009ef2212f0be0ac8c72736f0990ab94b0f64" dependencies = [ "miden-lifted-air", "miden-stark-transcript", @@ -2689,12 +2694,11 @@ dependencies = [ [[package]] name = "miden-mast-package" -version = "0.29.4" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1355a2563a52af4399b4a93120a4398f81415b1fc4edb58310de5034cfa3781d" +checksum = "daef2e2908d0da844ea8a2442ec793af6d41238f62520512d682377b1fb2c2b8" dependencies = [ "hashbrown 0.17.1", - "log", "miden-assembly-syntax", "miden-core", "miden-debug-types", @@ -2750,13 +2754,14 @@ dependencies = [ [[package]] name = "miden-node-proto-build" -version = "0.16.0" +version = "0.17.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "724a79e7663157e73de1fc19fcd6e30c7cee4e4c4189d44477922a3969797acb" +checksum = "5ea7fdb412d0a1e2b795cd5a433524bafeff0462a1e82a7f1aca000fb52869d5" dependencies = [ "build-rs", "codegen", "fs-err", + "miden-objects", "miette", "protox", "tonic-prost-build", @@ -2774,11 +2779,26 @@ dependencies = [ "tonic-prost-build", ] +[[package]] +name = "miden-objects" +version = "0.17.0-rc.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "131901dc84b43e3c51e982b23a62eaadaf8915e3a4f939ad97a2709a9be8693e" +dependencies = [ + "miden-protobuf", + "miden-protocol", + "prost", + "prost-build", + "protox", + "quote", + "thiserror", +] + [[package]] name = "miden-package-registry" -version = "0.29.4" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a799e66245492c193b0444c3e0ff0fe42418009ec668a3e80c40d9f5b1454aac" +checksum = "ff03ec43c9efb79983428f8c6292b26df2ba8189027ef601918a7b02fe2b0207" dependencies = [ "miden-assembly-syntax", "miden-core", @@ -2792,29 +2812,44 @@ dependencies = [ [[package]] name = "miden-precompiles" -version = "0.29.4" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "073ebeeb4413b9a03c60b0b066f94b8edaa6f50150017a92dc09d3ace92d6976" +checksum = "d173cbad18a65dcbf0399c0fd2fc3891b12ca3d54fc1a65c58c3fb36a7c68f23" dependencies = [ "miden-core", "miden-crypto", + "ruint", ] [[package]] -name = "miden-precompiles-prover" -version = "0.29.4" +name = "miden-precompiles-air" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a76663c4d90cc073f9e2d2aa2349b9dcd25bf6c40db5020d27ce59990b6bd5af" +checksum = "3fd5e2734f90018a633bd15088db2fa40fe581d65c851bc1c4f56aed57397821" dependencies = [ - "miden-ace-codegen", "miden-air", "miden-core", "miden-crypto", "miden-lifted-air", "miden-lifted-stark", "miden-precompiles", - "miden-serde-utils", + "p3-security", "ruint", +] + +[[package]] +name = "miden-precompiles-prover" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4367981502adfffdb1d9cc628dda488849e0b60dae8604ef72be2f19441a7096" +dependencies = [ + "miden-air", + "miden-core", + "miden-crypto", + "miden-lifted-air", + "miden-lifted-stark", + "miden-precompiles", + "miden-precompiles-air", "serde", "serde-wincode", "thiserror", @@ -2822,11 +2857,30 @@ dependencies = [ "wincode", ] +[[package]] +name = "miden-precompiles-verifier" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52502ee2a5a724f6e062eb53d1726e85c594fe56a770c0e4d0ca76df7e09e07" +dependencies = [ + "miden-ace-codegen", + "miden-core", + "miden-crypto", + "miden-lifted-air", + "miden-lifted-stark", + "miden-precompiles-air", + "miden-serde-utils", + "serde", + "serde-wincode", + "thiserror", + "wincode", +] + [[package]] name = "miden-processor" -version = "0.29.4" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c7331ab96f00f922d29e2f59285f539299061a44ffdeea31b853d2c071f8056" +checksum = "00dae9d2475210e61422c7e73d585d7d41179eed9e0efb834eb5116b8c5623ea" dependencies = [ "hashbrown 0.17.1", "itertools 0.15.0", @@ -2845,12 +2899,13 @@ dependencies = [ [[package]] name = "miden-project" -version = "0.29.4" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8201d3a6d0c85c747092309c3c420de42e61e76f71df2189a46411100d120a" +checksum = "0d9dd04eb2a6c7763de12a63c4af72a91c50b34e0fd93bc5e476d1415269adfc" dependencies = [ "miden-assembly-syntax", "miden-core", + "miden-debug-types", "miden-mast-package", "miden-package-registry", "proptest", @@ -2860,11 +2915,37 @@ dependencies = [ "toml", ] +[[package]] +name = "miden-protobuf" +version = "0.17.0-rc.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6b26d407e6722f0611da021a545be691ba235f893215dd2c06f6c5f7405fe43" +dependencies = [ + "miden-protobuf-derive", + "proc-macro-crate", + "prost", + "prost-build", + "prost-types", +] + +[[package]] +name = "miden-protobuf-derive" +version = "0.17.0-rc.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7675f068023c65c69e5ef591d8f46be61f02a35bdd3f866749f5dd4cbcd02533" +dependencies = [ + "heck", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "miden-protocol" -version = "0.16.1" +version = "0.17.0-rc.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ccf181d3a4e90b9e6ac107547ce1db30f085dfe5696a7735b1de03ac02f6ba" +checksum = "3e58b154de854083bbc71e03dfd6b8b193395c84a09dc644cc8a7e0a6f351d0d" dependencies = [ "bech32", "fs-err", @@ -2893,9 +2974,9 @@ dependencies = [ [[package]] name = "miden-protocol-build-utils" -version = "0.16.1" +version = "0.17.0-rc.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d16cdd96c1d0b3b2d57342d37eaadd15cc7c633bda965fc8c3b23ca249965b4" +checksum = "fab13bb4fac6ad39f2b751a9064ce94b944258fcc48a666b96dbfe19b0336624" dependencies = [ "fs-err", "miden-assembly", @@ -2909,9 +2990,9 @@ dependencies = [ [[package]] name = "miden-prover" -version = "0.29.4" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b20c4cabb7a032e7613adff4c637c267bbcaad0d0fa37f7c7addbe7d425bb5c4" +checksum = "2a6c1f620589fd2f3eaccf7766bb0b2d79989e49e9bdd72fc34a7aa5f886b29d" dependencies = [ "miden-air", "miden-core", @@ -2920,6 +3001,7 @@ dependencies = [ "miden-processor", "serde", "serde-wincode", + "thiserror", "tracing", ] @@ -2935,9 +3017,9 @@ dependencies = [ [[package]] name = "miden-serde-utils" -version = "0.29.4" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5c21a2acdc1928f86803b3ff3c44564c3f614a7dc47dcd64969a5c856d27988" +checksum = "f8379f86671de10753b0dbe09aed6a9d9f6e24b203288626bf350e04fb400037" dependencies = [ "p3-field", "p3-goldilocks", @@ -2946,9 +3028,9 @@ dependencies = [ [[package]] name = "miden-standards" -version = "0.16.1" +version = "0.17.0-rc.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6fc2cdac6d1bb48ae4b0c2f0498e754490750283783b7a71b25c3017e68754" +checksum = "e6e12c7a983ab0db83bf689279e33e730b1fde16c1cc7b03a5a48bcbd8ed2441" dependencies = [ "bon", "miden-assembly", @@ -2963,9 +3045,9 @@ dependencies = [ [[package]] name = "miden-stark-transcript" -version = "0.29.4" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d503630c353389838fa5d668a0d4550130453c7b2a72b802d4adc1ea39ab5bee" +checksum = "72c04c077240a679cebd9204253f06830c6af56449947fd27586fcd8ac4a02b9" dependencies = [ "p3-challenger", "p3-field", @@ -2975,9 +3057,9 @@ dependencies = [ [[package]] name = "miden-stateful-hasher" -version = "0.29.4" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8c2008195bdb552eeb744074bfc8822049552ccdf7aef3321a32e1ab6be92e6" +checksum = "10c4a3ab969061fc3e0ed2be4f8a5b1b0bc61cbfb607b487e9b40f7e7d5b3fe3" dependencies = [ "p3-field", "p3-symmetric", @@ -2985,9 +3067,9 @@ dependencies = [ [[package]] name = "miden-testing" -version = "0.16.1" +version = "0.17.0-rc.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "697ea989c4553c1676dd740e41ad29d35fa6b1ac2ab20893c647fa4c7ec6d9dd" +checksum = "5a100a9dbe195a0f11823016a69141e831576ab3072acb8193ddb332d2581d43" dependencies = [ "anyhow", "itertools 0.15.0", @@ -3006,9 +3088,9 @@ dependencies = [ [[package]] name = "miden-tx" -version = "0.16.1" +version = "0.17.0-rc.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d5e8e69cf0db2d2f37f16909fc17d0b28730e1d21a587580ec90f27e19e309f" +checksum = "72e9839cb96cdc9c15db05ebd08b423b3bf0b9ddb4e32e3023c7353a6763f4a7" dependencies = [ "bon", "miden-agglayer", @@ -3021,9 +3103,9 @@ dependencies = [ [[package]] name = "miden-tx-batch" -version = "0.16.1" +version = "0.17.0-rc.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06419d4c747d8e79674d2be1436f0a46abe70aa2598f1789e71a07c14106cfa9" +checksum = "cb096837d6a1cb4b47c21da46c5cc53af573dc5a543e9804589eb285344e911a" dependencies = [ "miden-processor", "miden-protocol", @@ -3034,9 +3116,9 @@ dependencies = [ [[package]] name = "miden-utils-core-derive" -version = "0.29.4" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "107d04fcd05b0308e6347113ee6dc13599755e5b4d05ecc08d8b5c05f36a5a39" +checksum = "33592a739277398937032f35e768733b10aad032d1ff42944c3e39ba86283abe" dependencies = [ "proc-macro2", "quote", @@ -3045,9 +3127,9 @@ dependencies = [ [[package]] name = "miden-utils-diagnostics" -version = "0.29.4" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3b444d204bf082cdab14015ff10d5b0b43a10fba662fef09e4753f6d07faf1f" +checksum = "be3f036f9d2faf08080ce0a059edd6c30aad1d80cab8245f80c70ebee85e3bc2" dependencies = [ "miden-debug-types", "miden-miette", @@ -3056,21 +3138,20 @@ dependencies = [ [[package]] name = "miden-utils-indexing" -version = "0.29.4" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ff225060e2a5cc4dd6c898eef1f04739461d3401b6de1692bf443cfdd302b0" +checksum = "a50071498356b0ac387c28f7265de512032e7d81b09625935fbaca96d65145ae" dependencies = [ "miden-serde-utils", "proptest", - "serde", "thiserror", ] [[package]] name = "miden-utils-sync" -version = "0.29.4" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76b9cb00f01787f8687447cd8a45b3888b470eb35a132df1be9729779d311fd7" +checksum = "f9723ffbf4fce2b5dd1173f4ff1ed2fa68772c3855b956c9604bf2376ba07be9" dependencies = [ "lock_api", "loom", @@ -3080,15 +3161,15 @@ dependencies = [ [[package]] name = "miden-verifier" -version = "0.29.4" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e049df6008af1fea5a66df8ce98075cc9e00f16122fd5f4e7c4be9a263cae46" +checksum = "11e4b3833fc7e086f4fcafb53c272ded890788f07a675e5c348eb2bc275d60d7" dependencies = [ "miden-air", "miden-core", "miden-crypto", "miden-precompiles", - "miden-precompiles-prover", + "miden-precompiles-verifier", "miden-serde-utils", "serde", "serde-wincode", @@ -3097,14 +3178,12 @@ dependencies = [ [[package]] name = "midenc-hir-type" -version = "0.10.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e03aa1e30a8eec3e08eba9a1fd17c7c4462f0d49dfbd64cb65193b68ee14cdcc" +checksum = "f112d8e7958b3992345f7155a5c36905ce0c9051d0c7ac6a6ddd1949f078c801" dependencies = [ "miden-formatting", "miden-serde-utils", - "serde", - "serde_repr", "smallvec", "thiserror", ] @@ -3375,20 +3454,21 @@ checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" [[package]] name = "p3-air" -version = "0.6.3" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddb1be05c0d6f691afe0c9f468018a9a37cfa904dee78a8081ec96eb3cdd88e8" +checksum = "1de1da5a48410a5a68356cb0c43e2642b6fab1bb5906d77bd33e3e4c4d2a7785" dependencies = [ "p3-field", "p3-matrix", + "serde", "tracing", ] [[package]] name = "p3-blake3" -version = "0.6.3" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f202f5fbcceb6f56f783d98efb5de27e5a171470e3364de97b0923b39c87ab5" +checksum = "04035123bce04faf2e6de7ef2fd2d5c250b225a3410f4591d3f352ffb1e8c9b8" dependencies = [ "blake3", "p3-symmetric", @@ -3397,13 +3477,12 @@ dependencies = [ [[package]] name = "p3-challenger" -version = "0.6.3" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d5d5e1ecf2c80b09b48ce870e8abd08b643454101c5dc9d0fd71bfbd78224d" +checksum = "d33cda6d6dadf1e541121a479589206b83877889d04a3e6a42610c834d348ed2" dependencies = [ "p3-field", "p3-maybe-rayon", - "p3-monty-31", "p3-symmetric", "p3-util", "tracing", @@ -3411,9 +3490,9 @@ dependencies = [ [[package]] name = "p3-dft" -version = "0.6.3" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4321a952da2721ecd85ca593ea189798dfb4e439a2cc1378ce1442091880f173" +checksum = "4344492a3a2981879411ef31b51b12fb364efeda59b9ac855d7d92559e7b46e8" dependencies = [ "itertools 0.15.0", "p3-field", @@ -3426,9 +3505,9 @@ dependencies = [ [[package]] name = "p3-field" -version = "0.6.3" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53db75d38e04fc255826f388eca9d05976733dc9754aa3db411bc9ea1a37c1a0" +checksum = "f45f7364ae3aac1bd42e8dfec4aa76d1bc9087c4749649c893a671e386e96727" dependencies = [ "itertools 0.15.0", "num-bigint 0.5.1", @@ -3442,12 +3521,11 @@ dependencies = [ [[package]] name = "p3-goldilocks" -version = "0.6.3" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d03b3f31080df31be723b876709246f8f1e532e1c5b82efb5281d705c8304c63" +checksum = "5c62b44c110832ba3d3a5c11160f591205e23f81a08ceb8c7be1a3a71fc4c214" dependencies = [ "num-bigint 0.5.1", - "p3-challenger", "p3-dft", "p3-field", "p3-mds", @@ -3463,9 +3541,9 @@ dependencies = [ [[package]] name = "p3-keccak" -version = "0.6.3" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae50c8c37eb847c660298fb275e53c025c49b2623a8cfabf67f5322258b2b4db" +checksum = "fc78b429da6c572d3d3764d5da86d711f75c0937c9821a8741dd0b4c2314819d" dependencies = [ "p3-symmetric", "p3-util", @@ -3474,9 +3552,9 @@ dependencies = [ [[package]] name = "p3-matrix" -version = "0.6.3" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "473eb920c446a6f4536e0d3528fbdca2a23c0e24e1d0d7767452e6d385dd335c" +checksum = "92f322bd7dded657d51f4c3adcc64bed27ffe4894dbd0f9c077d5670ce73217e" dependencies = [ "itertools 0.15.0", "p3-field", @@ -3489,18 +3567,18 @@ dependencies = [ [[package]] name = "p3-maybe-rayon" -version = "0.6.3" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6fddfd435f96394769414cf5590b77058aa506659bf20d6592e9d1989e04440" +checksum = "486d6396b0d22274f862a6c5987bb25f9ea61b34f21b54c23a2ee53f2d8c20ea" dependencies = [ "rayon", ] [[package]] name = "p3-mds" -version = "0.6.3" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "551ba0ab2cccd89f85a99450224898aff224e323bbf61f777ba6344f0896ef10" +checksum = "00e11f572a44aa3c91057e59625d0992aacc6ffdb80dc1b44948d5587d66a159" dependencies = [ "p3-dft", "p3-field", @@ -3510,59 +3588,48 @@ dependencies = [ ] [[package]] -name = "p3-monty-31" -version = "0.6.3" +name = "p3-poseidon1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "871f635f7340cd0868b17e43e0c98fefdafdaed90469d0725caf6d8372a2a47c" +checksum = "457e895bcf89b77e062653021b60c12ccd94dce599c54898d9ced4bfbabc820d" dependencies = [ - "itertools 0.15.0", - "num-bigint 0.5.1", - "p3-dft", "p3-field", - "p3-matrix", - "p3-maybe-rayon", "p3-mds", - "p3-poseidon1", - "p3-poseidon2", "p3-symmetric", - "p3-util", - "paste", "rand 0.10.2", - "serde", - "spin 0.12.2", - "tracing", ] [[package]] -name = "p3-poseidon1" -version = "0.6.3" +name = "p3-poseidon2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d0d304e9a1f29c0d66534aa84e69528e2118351fdce08dcf5898af4e0fecc32" +checksum = "65501f32d58b5055c9470ccff459b2576de5f4e6ba72e91f78457231bc67b1c6" dependencies = [ "p3-field", "p3-mds", "p3-symmetric", + "p3-util", "rand 0.10.2", ] [[package]] -name = "p3-poseidon2" -version = "0.6.3" +name = "p3-security" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43eb8a73a26d14becaed1c67c3e8a047e4311d7909b402383c82ca9643ba17c6" +checksum = "2347869521f4b61e8851ccb8327879a70e64291e884748d14ebc58bc0dd4dbe5" dependencies = [ + "libm", + "p3-air", "p3-field", - "p3-mds", - "p3-symmetric", "p3-util", - "rand 0.10.2", + "serde", ] [[package]] name = "p3-symmetric" -version = "0.6.3" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2015ea80cad969b6aabf27a04884286fe1354393b166d968ee0d80a95126b2a4" +checksum = "3335886a1e2bec45877e050f76e6bf7a1a77019111c5f599d8b41c5889a2ea1c" dependencies = [ "itertools 0.15.0", "p3-field", @@ -3572,11 +3639,11 @@ dependencies = [ [[package]] name = "p3-util" -version = "0.6.3" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c5466fc40e6df89d3b291a2eff16b33e68e8571207790370137ec18090aadab" +checksum = "e020b468e9188da32310d8c876225e5183df105a1970368df97f8a76b85dcc5b" dependencies = [ - "rayon", + "p3-maybe-rayon", "serde", ] @@ -3877,9 +3944,9 @@ dependencies = [ [[package]] name = "proptest-derive" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb6dc647500e84a25a85b100e76c85b8ace114c209432dc174f20aac11d4ed6c" +checksum = "c57924a81864dddafba92e1bf92f9bf82f97096c44489548a60e888e1547549b" dependencies = [ "proc-macro2", "quote", @@ -4648,17 +4715,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_repr" -version = "0.1.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - [[package]] name = "serde_spanned" version = "1.1.1" diff --git a/Cargo.toml b/Cargo.toml index 58a5c9ee..f0c87d24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,8 +45,13 @@ idxdb-store = { default-features = false, package = "miden-idxdb-store", pat js-export-macro = { path = "crates/js-export-macro", version = "0.16" } # Client crates -miden-client = { default-features = false, version = "0.16.1" } -miden-client-sqlite-store = { default-features = false, version = "0.16.1" } +# `miden-protocol` is the protocol crate `miden-client` itself resolves, needed directly only for +# the `SequentialCommit` trait, which `miden-client` does not re-export but which the multisig auth +# args are committed through. A requirement that drifts from `miden-client`'s would resolve a second +# copy of the protocol types, so bump the three together. +miden-client = { default-features = false, version = "0.17.0-rc.1" } +miden-client-sqlite-store = { default-features = false, version = "0.17.0-rc.1" } +miden-protocol = { default-features = false, version = "0.17.0-rc.5" } # External dependencies async-trait = { version = "0.1" } diff --git a/Makefile b/Makefile index 28b4e674..15c07bc6 100644 --- a/Makefile +++ b/Makefile @@ -110,8 +110,31 @@ hydrate-web-client: ## Populate crates/web-client/dist from the published npm ta .PHONY: test-coverage test-coverage: test-react-sdk test-idxdb-store test-vite-plugin test-web-client-unit ## Run all coverage gates +# Every Playwright suite that runs against a local node needs the chain's fee faucet: since 0.17 +# a client cannot execute or screen notes without one, and the fixtures now fail loudly rather +# than skipping silently. The node mints a fresh one at each genesis and prints it to its +# bootstrap log, which is the same extraction CI performs. Override TEST_NODE_BOOTSTRAP_LOG for a +# node started elsewhere, or export TEST_MIDEN_FEE_FAUCET_ID yourself to skip the lookup. +TEST_NODE_BOOTSTRAP_LOG ?= ../miden-client/target/test-node/data/logs/bootstrap.log +ifndef TEST_MIDEN_FEE_FAUCET_ID +# `:=` inside the guard, not a bare `?=`: an exported recursive variable is re-expanded while +# make builds the environment for every recipe line of every target, which would re-run this +# lookup across the whole build. An env or command-line value still wins, because the guard +# short-circuits before this runs. +TEST_MIDEN_FEE_FAUCET_ID := $(shell sed -n 's/^Native faucet account id: //p' $(TEST_NODE_BOOTSTRAP_LOG) 2>/dev/null | tail -1) +endif +export TEST_MIDEN_FEE_FAUCET_ID + +.PHONY: require-fee-faucet +require-fee-faucet: + @test -n "$(TEST_MIDEN_FEE_FAUCET_ID)" || { \ + echo "TEST_MIDEN_FEE_FAUCET_ID is unset and no faucet id was found in $(TEST_NODE_BOOTSTRAP_LOG)."; \ + echo "Start the test node, point TEST_NODE_BOOTSTRAP_LOG at its bootstrap log, or export the id."; \ + exit 1; \ + } + .PHONY: test-web-client-nodejs -test-web-client-nodejs: ## Run web client tests on Node.js (mock chain, no browser needed) +test-web-client-nodejs: require-fee-faucet ## Run web client tests on Node.js (mock chain, no browser needed) 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 --project=nodejs --workers=1 @@ -123,19 +146,19 @@ SHARD_PARAMETER ?= "" # `pnpm run script -- --project=X` appends to the LAST command in the chain, # making playwright see `-- --project=X` and treat `--project=X` as a # positional file regex. Splitting the steps in Make keeps args clean. -integration-test-web-client: ## Run integration tests for the web client (with a chromium browser) +integration-test-web-client: require-fee-faucet ## Run integration tests for the web client (with a chromium browser) pnpm install --no-frozen-lockfile cross-env MIDEN_WEB_DEV=true pnpm --filter @miden-sdk/miden-sdk run build pnpm --filter @miden-sdk/miden-sdk run test:install pnpm --filter @miden-sdk/miden-sdk run test:clean --project=chromium $(SHARD_PARAMETER) .PHONY: integration-test-web-client-webkit -integration-test-web-client-webkit: ## Run web client tests (webkit) +integration-test-web-client-webkit: require-fee-faucet ## Run web client tests (webkit) pnpm --filter @miden-sdk/miden-sdk run test:install pnpm --filter @miden-sdk/miden-sdk run test --project=webkit .PHONY: integration-test-remote-prover-web-client -integration-test-remote-prover-web-client: ## Run integration tests for the web client with remote prover +integration-test-remote-prover-web-client: require-fee-faucet ## Run integration tests for the web client with remote prover pnpm --filter @miden-sdk/miden-sdk run test:install pnpm --filter @miden-sdk/miden-sdk run test:remote_prover --project=chromium diff --git a/README.md b/README.md index 238e0e77..0d671e56 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,9 @@ import { MidenClient } from "@miden-sdk/miden-sdk"; const client = await MidenClient.create({ endpoint: "https://rpc.testnet.miden.xyz", + // Required: the faucet the chain mints its fee asset from. Since 0.17 that + // lives in a protocol configuration the node does not serve over RPC. + feeFaucetId: FEE_FAUCET, }); const account = await client.accounts.create({ storage: "public" }); @@ -221,7 +224,7 @@ import { MidenClient, initThreadPool } from "@miden-sdk/miden-sdk/mt/lazy"; await MidenClient.ready(); await initThreadPool(navigator.hardwareConcurrency); // once, at startup -const client = await MidenClient.createTestnet(); // prove calls now fan out across threads +const client = await MidenClient.createTestnet({ feeFaucetId: FEE_FAUCET }); // prove calls now fan out across threads ``` The [web-client README](crates/web-client/README.md#setting-cross-origin-isolation-headers) has the full header recipes (Vite, Next.js, Express, extension manifests), the COEP caveats, and a service-worker fallback for hosts where you can't add headers. diff --git a/crates/idxdb-store/src/chain_data/mod.rs b/crates/idxdb-store/src/chain_data/mod.rs index 8e3143fe..85955bd8 100644 --- a/crates/idxdb-store/src/chain_data/mod.rs +++ b/crates/idxdb-store/src/chain_data/mod.rs @@ -159,7 +159,12 @@ impl IdxdbStore { return Ok(BTreeMap::new()); } - let max_in_order_index = forest.rightmost_in_order_index().inner().to_string(); + // `rightmost_in_order_index` is `None` only for an empty forest, which the + // check above already returned for. + let Some(rightmost) = forest.rightmost_in_order_index() else { + return Ok(BTreeMap::new()); + }; + let max_in_order_index = rightmost.inner().to_string(); let promise = idxdb_get_partial_blockchain_nodes_up_to_inorder_index( self.db_id(), max_in_order_index, diff --git a/crates/web-client/AGENTS.md b/crates/web-client/AGENTS.md index 823efa5a..33ba38c6 100644 --- a/crates/web-client/AGENTS.md +++ b/crates/web-client/AGENTS.md @@ -39,7 +39,7 @@ never with `new` - and route work through its typed resources: ```ts import { MidenClient } from "@miden-sdk/miden-sdk"; -const client = await MidenClient.createTestnet(); +const client = await MidenClient.createTestnet({ feeFaucetId: FEE_FAUCET }); await client.sync(); ``` @@ -47,6 +47,10 @@ await client.sync(); `createDevnet()` are preconfigured; `createMock()` backs tests with an in-memory chain and no network. +Every non-mock client names the chain's fee faucet. Since 0.17 the fee asset +lives in a protocol configuration the node does not serve over RPC, and the SDK +carries a per-network default for no network yet, so creation without it fails. + State is split across resources rather than living on the client: `accounts`, `transactions`, `notes`, `tags`, `settings`, `keystore`, `compile` and `pswap`. Client-level methods cover the lifecycle around them - `sync`, diff --git a/crates/web-client/Cargo.toml b/crates/web-client/Cargo.toml index e4ca75f1..73f79c31 100644 --- a/crates/web-client/Cargo.toml +++ b/crates/web-client/Cargo.toml @@ -71,7 +71,8 @@ mt-threads = ["dep:rayon", "dep:wasm-bindgen-rayon", "miden-client/concurrent"] [dependencies] # Workspace dependencies -miden-client = { features = ["testing"], workspace = true } +miden-client = { features = ["testing"], workspace = true } +miden-protocol = { workspace = true } # Proc macro for dual wasm_bindgen/napi annotations. The workspace dependency carries both a path # and version so the published manifest remains valid. diff --git a/crates/web-client/README.md b/crates/web-client/README.md index 79fa714a..27caea24 100644 --- a/crates/web-client/README.md +++ b/crates/web-client/README.md @@ -265,9 +265,15 @@ import { MidenClient, AccountId, Felt } from "@miden-sdk/miden-sdk"; const id = AccountId.fromHex("0x…"); // sync, WASM is already initialized const felt = new Felt(42n); // sync -const client = await MidenClient.createTestnet(); +const client = await MidenClient.createTestnet({ feeFaucetId: FEE_FAUCET }); ``` +Every non-mock constructor needs `feeFaucetId`. Since 0.17 the chain's fee asset +lives in a protocol configuration the node does not serve over RPC, and the SDK +carries a per-network default for no network yet, so a client created without it +fails with an error naming the option. Snippets below leave it out where the +point they make is something else. + ### Lazy usage (`/lazy`) ```typescript @@ -601,9 +607,11 @@ const builder = await client.feeAwareTransactionRequestBuilder(wallet); const request = builder.withCustomScript(script).build(); ``` -The argument is the account that will **execute** the request — the one whose auth procedure pays. It is a safe drop-in for `new TransactionRequestBuilder()`: on a chain whose `BlockHeader.verificationBaseFee()` is zero, or for any account that does not choose its own salt, the builder comes back untouched. +The argument is the account that will **execute** the request — the one whose auth procedure pays. It is a safe drop-in for `new TransactionRequestBuilder()`: for an account that is not a multisig the builder comes back untouched. A zero base fee is not a second condition: since 0.17 a multisig resolves its auth args whatever the chain charges, so a multisig gets them on a fee-free chain too. + +To set the salt yourself — which co-signers must do when they need to agree on it without transporting the proposer's bytes — pass it to `feeAwareTransactionRequestBuilder`, together with the block the summary binds: `client.feeAwareTransactionRequestBuilder(multisig, { feeConversionSalt: salt, boundBlockNum: block })`. Both are bound by the summary, so two parties who disagree on either can never derive the same one. Each call consumes the `Word`: it is moved across the WASM boundary, so a second build needs a freshly constructed one, and a spent handle arrives as "no salt given" rather than as an error. A co-signer who has the proposer's serialized request needs neither — it carries the auth argument and its advice-map preimage. -To set the salt yourself — which co-signers must do when they need to agree on it without transporting the proposer's bytes — call `builder.withFeeConversionSalt(salt)`. It is a declaration rather than a commitment: `request.feeConversionSalt()` reports it back, `request.authArg()` stays empty, and it survives serialization. `withAuthArg` and `withFeeConversionSalt` are mutually exclusive, and miden-client enforces that by having each setter clear the other, so whichever is called last wins rather than erroring. For a custom auth procedure that reads `AUTH_ARGS` as conversion info, compute the commitment yourself and attach it with `withAuthArg` plus `extendAdviceMap` — setting an auth argument opts the request out of the client's fee machinery, which commits only when the request carries none. Declaring a salt against such an account instead is rejected with `FeeConversionInfoUnsupported`. +Do **not** reach for `builder.withFeeConversionSalt(salt)` on that builder. `withAuthArg` and `withFeeConversionSalt` are mutually exclusive, and miden-client enforces that by having each setter clear the other, so calling it discards the three-word multisig auth args the builder already carries and the transaction aborts in the auth procedure. On a bare `new TransactionRequestBuilder()` the setter is still a declaration rather than a commitment — `request.feeConversionSalt()` reports it back, `request.authArg()` stays empty, and it survives serialization — which is what a single-sig or custom-auth caller wants. For a custom auth procedure that reads `AUTH_ARGS` as conversion info, compute the commitment yourself and attach it with `withAuthArg` plus `extendAdviceMap` — setting an auth argument opts the request out of the client's fee machinery, which commits only when the request carries none. Declaring a salt against such an account instead is rejected with `FeeConversionInfoUnsupported`. One path the SDK cannot declare a salt on: `client.pswap.cancelByOrder` builds its request inside miden-client, so there is no builder. An ordinary creator has its conversion info committed and pays normally; a multisig creator fails with `FeeConversionInfoRequired`, so cancel by note with `client.transactions.pswapCancel` there. See the [transactions guide](https://docs.miden.xyz/builder/tools/clients/web-client/library/transactions) for the full narrative. @@ -679,7 +687,7 @@ See [the transactions guide](https://github.com/0xMiden/web-sdk/blob/main/docs/e ### Foreign Accounts -A transaction that invokes a procedure on another account declares it as a `ForeignAccount`. Three kinds: +A transaction that invokes a procedure on another account declares it as a `ForeignAccount`. Two kinds: ```typescript import { ForeignAccount, AccountStorageRequirements } from "@miden-sdk/miden-sdk"; @@ -689,19 +697,9 @@ ForeignAccount.public(oracleAccountId, new AccountStorageRequirements()); // Private — the caller supplies the state; only an inclusion proof is fetched. ForeignAccount.private(account); - -// Prefetched — the caller supplies state and witness; nothing is fetched. -const blockNum = await client.getSyncHeight(); -const inputs = await client.transactions.foreignAccountInputs( - [ForeignAccount.public(oracleAccountId, new AccountStorageRequirements())], - blockNum -); -ForeignAccount.prefetched(inputs[0]); ``` -A witness opens against the account tree of exactly one block, so inputs fetched at block `N` are valid only for a transaction whose reference block is `N` — the anchor's block under chain-anchored execution, or the sync height otherwise. Don't sync between fetching and executing. - -Prefetched inputs serialize (`inputs[0].serialize()` / `AccountInputs.deserialize(bytes)`), so one client can fetch them and another can execute against them, and a transaction pinned to an older block can still execute after the node stops serving account state there. +A public entry's inputs are fetched against the transaction's reference block, and the vault and storage maps the foreign code actually reads are resolved during execution as per-asset and per-key witnesses rather than up front. A transaction pinned to a block the node no longer serves account state for therefore cannot execute: pin it to a recent block instead. ### Partial-Swap (PSWAP) Orders @@ -779,7 +777,11 @@ To create the receiving account, build a **public** account carrying the network ```typescript // Each allowed note script carries the fee charged to consume it, in the -// fungible asset of `feeFaucetId`. Zero is a valid price. +// chain's fee asset. Zero is a valid price. The fee faucet must be the chain's +// own: the node never runs network transactions for an account whose fee asset +// differs from the chain's protocol configuration, and says nothing to the +// client - the account's notes are simply never consumed. +const feeFaucetId = await client.feeFaucetId(); const components = AccountComponent.createNetworkAuthComponents( [new NoteScriptFee(myNoteScript.root(), 0n)], feeFaucetId @@ -794,7 +796,7 @@ for (const component of components) builder.withComponent(component); const { account } = builder.build(); ``` -The allowlist must be non-empty. The canonical expiration transaction script is always allowlisted, since the node attaches it to every network transaction; any other transaction script is forbidden unless allowlisted via the optional third argument (`TransactionScript.root()`). The component bumps the nonce itself, so the account deploys via a scriptless transaction. Readback: `account.isNetworkAccount()` and `account.networkNoteAllowlist()`. +The allowlist must be non-empty. The canonical expiration transaction script is always allowlisted, since the node attaches it to every network transaction; any other transaction script is forbidden unless allowlisted via the optional third argument (`TransactionScript.root()`). Deploying the account needs an effect: since 0.17 the auth component asserts the transaction consumed an input note, created an output note, or changed account state before it pays the fee, so an empty transaction aborts. Consume a note the account allowlists, or run an allowlisted transaction script that changes its state. Readback: `account.isNetworkAccount()` and `account.networkNoteAllowlist()`. ### Cleanup diff --git a/crates/web-client/js/__tests__/client-options-observer.test.js b/crates/web-client/js/__tests__/client-options-observer.test.js index 95511346..b66b4504 100644 --- a/crates/web-client/js/__tests__/client-options-observer.test.js +++ b/crates/web-client/js/__tests__/client-options-observer.test.js @@ -303,11 +303,40 @@ describe("MidenClient.create forwards the observability options", () => { observeSensitive: true, }); expect(calls.createClient).toHaveLength(1); - const observability = calls.createClient[0].at(-1); + // `feeFaucetId` is the factory's last positional argument, so the + // observability bag is the one before it. + const args = calls.createClient[0]; + expect(args).toHaveLength(8); + const observability = args[6]; expect(observability.observer).toBe(observer); expect(observability.observeSensitive).toBe(true); }); + it("passes feeFaucetId to createClient as its last argument", async () => { + const calls = captureFactory(); + await MidenClient.create({ + rpcUrl: "testnet", + feeFaucetId: "0x1234567890abcdef", + }); + expect(calls.createClient).toHaveLength(1); + const args = calls.createClient[0]; + expect(args).toHaveLength(8); + expect(args[7]).toBe("0x1234567890abcdef"); + }); + + it("passes feeFaucetId to the external-keystore factory as its last argument", async () => { + const calls = captureFactory(); + await MidenClient.create({ + rpcUrl: "testnet", + keystore: { getKey: () => {}, insertKey: () => {}, sign: () => {} }, + feeFaucetId: "0x1234567890abcdef", + }); + expect(calls.createClientWithExternalKeystore).toHaveLength(1); + const args = calls.createClientWithExternalKeystore[0]; + expect(args).toHaveLength(11); + expect(args[10]).toBe("0x1234567890abcdef"); + }); + it("passes the observability options to the external-keystore factory", async () => { const calls = captureFactory(); const observer = () => {}; @@ -318,7 +347,9 @@ describe("MidenClient.create forwards the observability options", () => { observeSensitive: true, }); expect(calls.createClientWithExternalKeystore).toHaveLength(1); - const observability = calls.createClientWithExternalKeystore[0].at(-1); + const args = calls.createClientWithExternalKeystore[0]; + expect(args).toHaveLength(11); + const observability = args[9]; expect(observability.observer).toBe(observer); expect(observability.observeSensitive).toBe(true); }); @@ -326,7 +357,12 @@ describe("MidenClient.create forwards the observability options", () => { it("passes no observability options when none were supplied", async () => { const calls = captureFactory(); await MidenClient.create({ rpcUrl: "testnet" }); - const observability = calls.createClient[0].at(-1); + // Index 6, not -1: feeFaucetId is now the trailing argument, so a + // position counted from the end reads the faucet and these optional-chained + // assertions pass whatever the observability bag holds. + const args = calls.createClient[0]; + expect(args).toHaveLength(8); + const observability = args[6]; expect(observability?.observer).toBeUndefined(); expect(observability?.observeSensitive).toBeUndefined(); }); diff --git a/crates/web-client/js/__tests__/client.test.js b/crates/web-client/js/__tests__/client.test.js index 28c661d6..71f50b0c 100644 --- a/crates/web-client/js/__tests__/client.test.js +++ b/crates/web-client/js/__tests__/client.test.js @@ -52,7 +52,10 @@ describe("MidenClient.feeAwareTransactionRequestBuilder", () => { await client.feeAwareTransactionRequestBuilder("0xabc"); expect(wasm.AccountId.fromHex).toHaveBeenCalledWith("0xabc"); expect(inner.feeAwareTransactionRequestBuilder).toHaveBeenCalledWith( - expect.objectContaining({ kind: "fromHex", hex: "0xabc" }) + expect.objectContaining({ kind: "fromHex", hex: "0xabc" }), + undefined, + undefined, + undefined ); }); @@ -60,7 +63,10 @@ describe("MidenClient.feeAwareTransactionRequestBuilder", () => { await client.feeAwareTransactionRequestBuilder("mtst1qabc"); expect(wasm.AccountId.fromBech32).toHaveBeenCalledWith("mtst1qabc"); expect(inner.feeAwareTransactionRequestBuilder).toHaveBeenCalledWith( - expect.objectContaining({ kind: "fromBech32", bech32: "mtst1qabc" }) + expect.objectContaining({ kind: "fromBech32", bech32: "mtst1qabc" }), + undefined, + undefined, + undefined ); }); @@ -69,7 +75,12 @@ describe("MidenClient.feeAwareTransactionRequestBuilder", () => { const account = { id: vi.fn(() => id) }; await client.feeAwareTransactionRequestBuilder(account); expect(account.id).toHaveBeenCalled(); - expect(inner.feeAwareTransactionRequestBuilder).toHaveBeenCalledWith(id); + expect(inner.feeAwareTransactionRequestBuilder).toHaveBeenCalledWith( + id, + undefined, + undefined, + undefined + ); }); it("passes an AccountId through untouched", async () => { @@ -77,7 +88,10 @@ describe("MidenClient.feeAwareTransactionRequestBuilder", () => { await client.feeAwareTransactionRequestBuilder(accountId); expect(wasm.AccountId.fromHex).not.toHaveBeenCalled(); expect(inner.feeAwareTransactionRequestBuilder).toHaveBeenCalledWith( - accountId + accountId, + undefined, + undefined, + undefined ); }); @@ -88,6 +102,38 @@ describe("MidenClient.feeAwareTransactionRequestBuilder", () => { expect(inner.feeAwareTransactionRequestBuilder).not.toHaveBeenCalled(); }); + // Every multisig override is off unless asked for: the cases above pin the + // `undefined`s that mean "SDK default", these pin that each option reaches the + // WASM boundary unchanged, in its own positional slot. + it("forwards an approval expiration delta", async () => { + const accountId = { kind: "accountId" }; + await client.feeAwareTransactionRequestBuilder(accountId, { + approvalExpirationDelta: 100, + }); + expect(inner.feeAwareTransactionRequestBuilder).toHaveBeenCalledWith( + accountId, + 100, + undefined, + undefined + ); + }); + + // The two a co-signer needs to reproduce a proposal rather than receive it. + it("forwards the salt and bound block a co-signer must agree on", async () => { + const accountId = { kind: "accountId" }; + const salt = { kind: "word" }; + await client.feeAwareTransactionRequestBuilder(accountId, { + feeConversionSalt: salt, + boundBlockNum: 42, + }); + expect(inner.feeAwareTransactionRequestBuilder).toHaveBeenCalledWith( + accountId, + undefined, + salt, + 42 + ); + }); + it("throws once terminated", async () => { client.terminate(); await expect( diff --git a/crates/web-client/js/__tests__/node-exports.test.js b/crates/web-client/js/__tests__/node-exports.test.js index eb48733d..71a08fdc 100644 --- a/crates/web-client/js/__tests__/node-exports.test.js +++ b/crates/web-client/js/__tests__/node-exports.test.js @@ -1,21 +1,33 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; -// These exports are JS polyfills and must work independently of native exports. -vi.mock("../node/loader.js", () => ({ loadNativeModule: () => ({}) })); +import { createSdkWrapper } from "../node/napi-compat.js"; -import { AccountInputsArray } from "../node-index.js"; +// napi takes plain JS arrays, but the browser SDK requires typed wrappers, so the +// Node build polyfills each array type. A polyfill that stopped behaving like an +// array would break every cross-platform call site that constructs one. +const sdk = createSdkWrapper({}); -describe("Node array exports", () => { - it.each([["AccountInputsArray", AccountInputsArray]])( - "exports a usable %s constructor", - (_name, ArrayType) => { - const item = {}; - const items = new ArrayType([item]); +describe("Node array polyfills", () => { + it.each([ + ["FeltArray"], + ["ForeignAccountArray"], + ["NoteAndArgsArray"], + ["OutputNotesArray"], + ])("exposes a usable %s constructor", (name) => { + const ArrayType = sdk[name]; + expect(typeof ArrayType).toBe("function"); - expect(Array.isArray(items)).toBe(true); - expect(items).toHaveLength(1); - expect(items[0]).toBe(item); - expect(items.get(0)).toBe(item); - } - ); + const item = {}; + const items = new ArrayType([item]); + + expect(Array.isArray(items)).toBe(true); + expect(items).toHaveLength(1); + expect(items[0]).toBe(item); + expect(items.get(0)).toBe(item); + }); + + it("starts empty when constructed with no items", () => { + expect(new sdk.FeltArray()).toHaveLength(0); + expect(new sdk.FeltArray(null)).toHaveLength(0); + }); }); diff --git a/crates/web-client/js/__tests__/resources/transactions.test.js b/crates/web-client/js/__tests__/resources/transactions.test.js index 21760e01..f0d002d2 100644 --- a/crates/web-client/js/__tests__/resources/transactions.test.js +++ b/crates/web-client/js/__tests__/resources/transactions.test.js @@ -97,6 +97,8 @@ function makeNoteArray() { }; } +let lastBuilder = null; + function makeTxRequestBuilder() { const self = { withOwnOutputNotes: vi.fn().mockReturnThis(), @@ -144,7 +146,13 @@ function makeWasm(overrides = {}) { ForeignAccount: { public: vi.fn().mockReturnValue("foreignAcc"), }, - ForeignAccountArray: vi.fn().mockReturnValue("foreignAccArray"), + // The real array is push-based and consumes what it is given, so the double + // records pushes: createNetworkNote has to declare the target network account, + // and a returned string would hide whether it did. + ForeignAccountArray: vi.fn().mockImplementation(function () { + const pushed = []; + return { pushed, push: (account) => pushed.push(account) }; + }), AccountStorageRequirements: vi.fn().mockReturnValue("storageReqs"), AdviceInputs: vi.fn().mockReturnValue("adviceInputs"), NetworkAccountTarget: vi.fn().mockImplementation(() => networkTarget), @@ -212,9 +220,12 @@ function makeInner(overrides = {}) { newMintTransactionRequest: vi.fn().mockResolvedValue("mintRequest"), newB2AggTransactionRequest: vi.fn().mockResolvedValue("b2aggRequest"), newConsumeTransactionRequest: vi.fn().mockResolvedValue("consumeRequest"), - feeAwareTransactionRequestBuilder: vi - .fn() - .mockImplementation(async () => makeTxRequestBuilder()), + // Keep the builder the resource actually used, so a test can assert what was + // declared on it rather than only that one was asked for. + feeAwareTransactionRequestBuilder: vi.fn().mockImplementation(async () => { + lastBuilder = makeTxRequestBuilder(); + return lastBuilder; + }), newSwapTransactionRequest: vi.fn().mockResolvedValue("swapRequest"), newPswapCreateTransactionRequest: vi .fn() @@ -268,63 +279,6 @@ function makeResource( // ── Tests ────────────────────────────────────────────────────────────────────── describe("TransactionsResource", () => { - describe("foreignAccountInputs", () => { - it.each(["browser", "node"])( - "preserves caller handles and result order on %s", - async (platform) => { - const foreignAccounts = ["z", "a"].map((id) => ({ - id, - consumed: false, - accountId() { - if (this.consumed) throw new Error("account handle was consumed"); - return this.id; - }, - })); - class BrowserArray { - constructor(items = []) { - this.items = items.map((item) => { - const copy = { id: item.accountId() }; - item.consumed = true; - return copy; - }); - } - push(item) { - this.items.push({ id: item.accountId() }); - } - } - const getForeignAccountInputs = vi.fn(async (accounts) => { - const inputs = ( - platform === "browser" ? accounts.items : accounts - ).map((account) => ({ accountId: () => account.id })); - return platform === "browser" - ? { length: () => inputs.length, get: (i) => inputs[i] } - : inputs; - }); - const { resource } = makeResource( - { getForeignAccountInputs }, - {}, - { ForeignAccountArray: platform === "browser" ? BrowserArray : Array } - ); - - for (let attempt = 0; attempt < 2; attempt++) { - const inputs = await resource.foreignAccountInputs( - foreignAccounts, - 42 - ); - expect(inputs.map((input) => input.accountId())).toEqual(["z", "a"]); - expect(foreignAccounts.map((account) => account.accountId())).toEqual( - ["z", "a"] - ); - } - expect(getForeignAccountInputs).toHaveBeenCalledTimes(2); - expect(getForeignAccountInputs).toHaveBeenLastCalledWith( - expect.anything(), - 42 - ); - } - ); - }); - describe("send — default path", () => { it("builds send request and submits", async () => { const { resource, inner } = makeResource(); @@ -491,6 +445,18 @@ describe("TransactionsResource", () => { expect.anything(), undefined ); + // Since 0.17 the kernel prices the note through a procedure call on the + // target, so the emitting request declares it as a foreign account, which + // pins that state at the reference block rather than depending on the + // client resolving the account lazily. + // `new AccountStorageRequirements()` returns the constructed double, not the + // mock's return value, so only the account is matched exactly here. + expect(wasm.ForeignAccount.public).toHaveBeenCalledWith( + "targetIdObj", + expect.anything() + ); + const declared = lastBuilder.withForeignAccounts.mock.calls[0][0]; + expect(declared.pushed).toEqual(["foreignAcc"]); expect(wasm.NoteTag.withAccountTarget).toHaveBeenCalledWith( "targetIdObj" ); @@ -1378,9 +1344,6 @@ describe("TransactionsResource", () => { "waitFor", // Receives the request directly and has no options bag. "captureAnchor", - // Takes its reference block as a positional argument, and fetches - // rather than executes, so there is no options bag and no tip. - "foreignAccountInputs", ]); const { declared, guarded } = analyzeResource(source); diff --git a/crates/web-client/js/client.js b/crates/web-client/js/client.js index 80eb3195..bd7cbea1 100644 --- a/crates/web-client/js/client.js +++ b/crates/web-client/js/client.js @@ -121,6 +121,12 @@ export class MidenClient { * If no `rpcUrl` is provided, defaults to testnet with full configuration * (RPC, prover, note transport, autoSync). * + * **Requires `feeFaucetId` today.** Since 0.17 the chain's fee asset lives in a + * protocol configuration the node does not serve, so the client has to be told + * which faucet mints it. The SDK carries a per-network default for no network + * yet, so every non-mock client must name one or creation fails with an error + * saying so. + * * @param {ClientOptions} [options] - Client configuration options. * @returns {Promise} A fully initialized client. */ @@ -164,7 +170,8 @@ export class MidenClient { options.keystore.sign, undefined, useWorker, - options + options, + options?.feeFaucetId ); } else { inner = await WebClientClass.createClient( @@ -174,7 +181,8 @@ export class MidenClient { options?.storeName, undefined, useWorker, - options + options, + options?.feeFaucetId ); } @@ -199,6 +207,12 @@ export class MidenClient { * Defaults: rpcUrl "testnet", proverUrl "testnet", noteTransportUrl "testnet", autoSync true. * All defaults can be overridden via options. * + * **Requires `feeFaucetId` today.** Since 0.17 the chain's fee asset lives in a + * protocol configuration the node does not serve, so the client has to be told + * which faucet mints it. The SDK carries a per-network default for no network + * yet, so every non-mock client must name one or creation fails with an error + * saying so. + * * @param {ClientOptions} [options] - Options to override defaults. * @returns {Promise} A fully initialized testnet client. */ @@ -218,6 +232,12 @@ export class MidenClient { * Defaults: rpcUrl "devnet", proverUrl "devnet", noteTransportUrl "devnet", autoSync true. * All defaults can be overridden via options. * + * **Requires `feeFaucetId` today.** Since 0.17 the chain's fee asset lives in a + * protocol configuration the node does not serve, so the client has to be told + * which faucet mints it. The SDK carries a per-network default for no network + * yet, so every non-mock client must name one or creation fails with an error + * saying so. + * * @param {ClientOptions} [options] - Options to override defaults. * @returns {Promise} A fully initialized devnet client. */ @@ -403,6 +423,22 @@ export class MidenClient { this.terminate(); } + /** + * Returns the fee faucet of the protocol configuration this client + * registered at creation. + * + * Replaces `BlockHeader.feeFaucetId()`: since 0.17 the fee asset lives in the + * protocol configuration rather than the block header, so it is the + * configuration the client registered at creation that names it - the + * `feeFaucetId` option, or, for a mock client, the mock chain's own. + * + * @returns {Promise} The fee faucet's account ID. + */ + async feeFaucetId() { + this.assertNotTerminated(); + return await this.#inner.feeFaucetId(); + } + /** * Returns the identifier of the underlying store (e.g. IndexedDB database name, file path). * @@ -427,20 +463,46 @@ export class MidenClient { * fails with `FeeConversionInfoRequired`. * * The argument is the account that **executes** the request — the one whose - * auth procedure pays — not the recipient or a note's sender. On a zero-fee - * chain, or for an account that does not choose its own salt, the builder - * comes back untouched, so this is a safe drop-in. `withAuthArg` and + * auth procedure pays — not the recipient or a note's sender. For an account + * that is not a multisig the builder comes back untouched, so this is a safe + * drop-in; a zero base fee is not a second condition, since 0.17 a multisig + * resolves its auth args whatever the chain charges. `withAuthArg` and * `withFeeConversionSalt` are mutually exclusive: each clears the other, so * whichever is called last wins. * + * Three options let a caller pin what the approvers sign over. All are + * multisig-only and all are defaulted when omitted. + * + * `feeConversionSalt` and `boundBlockNum` are what a co-signer needs to + * REPRODUCE a proposal rather than receive one. Left out, the salt is drawn + * fresh and the block is the store's sync height: right for the party + * creating the proposal, wrong for anyone rebuilding it, since both are bound + * by the summary. A co-signer holding the proposer's serialized request needs + * neither - it carries the auth argument and its advice-map preimage. + * + * Do not call `withFeeConversionSalt` or `withAuthArg` on the builder this + * returns for a multisig: the two setters clear each other, so either one + * discards the auth args this already set. Pass `feeConversionSalt` here. + * * @param {AccountRef} account - The executing account. + * @param {object} [options] - Multisig-only overrides. + * @param {number} [options.approvalExpirationDelta] - Expires the approvers' + * signatures this many blocks after the block the summary binds. Omit it + * for an approval that never expires; at least 1. + * @param {Word} [options.feeConversionSalt] - The salt the summary binds. + * Consumed by the call: build a fresh `Word` per call, since a spent handle + * arrives as "no salt given" rather than as an error. + * @param {number} [options.boundBlockNum] - The block the summary binds. * @returns {Promise} A fee-aware builder. */ - async feeAwareTransactionRequestBuilder(account) { + async feeAwareTransactionRequestBuilder(account, options) { this.assertNotTerminated(); const wasm = await this.#getWasm(); return await this.#inner.feeAwareTransactionRequestBuilder( - resolveAccountRef(account, wasm) + resolveAccountRef(account, wasm), + options?.approvalExpirationDelta, + options?.feeConversionSalt, + options?.boundBlockNum ); } diff --git a/crates/web-client/js/index.js b/crates/web-client/js/index.js index 12e9f98a..a618fa8e 100644 --- a/crates/web-client/js/index.js +++ b/crates/web-client/js/index.js @@ -152,7 +152,6 @@ const READ_METHODS = new Set([ "getAccountVault", "getAccounts", "getConsumableNotes", - "getForeignAccountInputs", "getInputNote", "getInputNotes", "getOutputNote", @@ -167,6 +166,7 @@ const READ_METHODS = new Set([ "listSettingKeys", "listTags", "executeProgram", + "feeFaucetId", "storeIdentifier", ]); @@ -421,6 +421,11 @@ class WebClient { * the process-wide observation sink; `observeSensitive` decides, for this * client and for its whole lifetime, whether observations carry the * high-fidelity `sensitive` channel. Both are construction-only. + * @param {string | undefined} [feeFaucetId] - Faucet of the chain's fee asset, + * as a bech32 address or a hex account ID. Since 0.17 the fee asset lives in + * the protocol configuration rather than the block header, and a client that + * cannot build one can neither execute nor screen notes, so this is required + * for a network the SDK knows no fee faucet for. */ constructor( rpcUrl, @@ -432,7 +437,8 @@ class WebClient { signCb, logLevel, useWorker = true, - observability + observability, + feeFaucetId ) { this.rpcUrl = rpcUrl; this.noteTransportUrl = noteTransportUrl; @@ -442,6 +448,11 @@ class WebClient { this.insertKeyCb = insertKeyCb; this.signCb = signCb; this.logLevel = logLevel; + // Stored under a private name on purpose: `createClientProxy` forwards only + // properties MISSING from this instance (`prop in target` wins), so an own + // property called `feeFaucetId` would shadow the WASM accessor of that name + // and `client.feeFaucetId()` would return this string instead of calling it. + this._feeFaucetId = feeFaucetId; this.useWorker = useWorker !== false; // Check if Web Workers are available AND the caller didn't opt out via @@ -780,6 +791,7 @@ class WebClient { !!this.signCb, this.logLevel, numThreads, + this._feeFaucetId, ], }); } @@ -814,6 +826,10 @@ class WebClient { * @returns {Promise} The fully initialized WebClient. * @param {{observer?: (observation: object) => void, observeSensitive?: boolean}} [observability] * - Observability fields of `ClientOptions`; see the constructor. + * @param {string | undefined} feeFaucetId - Fee faucet of the chain, as a bech32 address or a + * hex account ID. Required for a network the SDK knows no fee faucet for: since 0.17 the fee + * asset lives in the protocol configuration rather than the block header, and a client + * without one cannot execute. */ static async createClient( rpcUrl, @@ -822,7 +838,8 @@ class WebClient { network, logLevel, useWorker = true, - observability + observability, + feeFaucetId ) { // Construct the instance (synchronously). const instance = new WebClient( @@ -835,7 +852,8 @@ class WebClient { undefined, logLevel, useWorker, - observability + observability, + feeFaucetId ); // Set up logging on the main thread before creating the client. @@ -846,7 +864,13 @@ class WebClient { // Wait for the underlying wasmWebClient to be initialized. const wasmWebClient = await instance.getWasmWebClient(); - await wasmWebClient.createClient(rpcUrl, noteTransportUrl, seed, network); + await wasmWebClient.createClient( + rpcUrl, + noteTransportUrl, + seed, + network, + feeFaucetId + ); // Wait for the worker to be ready await instance.ready; @@ -883,7 +907,8 @@ class WebClient { signCb, logLevel, useWorker = true, - observability + observability, + feeFaucetId ) { // Construct the instance (synchronously). const instance = new WebClient( @@ -896,7 +921,8 @@ class WebClient { signCb, logLevel, useWorker, - observability + observability, + feeFaucetId ); // Set up logging on the main thread before creating the client. @@ -912,6 +938,7 @@ class WebClient { noteTransportUrl, seed, storeName, + feeFaucetId, getKeyCb, insertKeyCb, signCb diff --git a/crates/web-client/js/node-index.js b/crates/web-client/js/node-index.js index 777fe523..8f4b703a 100644 --- a/crates/web-client/js/node-index.js +++ b/crates/web-client/js/node-index.js @@ -140,8 +140,6 @@ export const exportStore = /* @__PURE__ */ _reexport("exportStore"); export const importStore = /* @__PURE__ */ _reexport("importStore"); // Array wrappers are JS polyfills, so the native export generator cannot discover them. -export const AccountInputsArray = - /* @__PURE__ */ _reexport("AccountInputsArray"); // Every other public napi class. GENERATED — do not edit by hand. Run // `pnpm --filter @miden-sdk/miden-sdk gen:node-reexports` to regenerate from the @@ -161,7 +159,6 @@ export const AccountDelta = /* @__PURE__ */ _reexport("AccountDelta"); export const AccountFile = /* @__PURE__ */ _reexport("AccountFile"); export const AccountHeader = /* @__PURE__ */ _reexport("AccountHeader"); export const AccountId = /* @__PURE__ */ _reexport("AccountId"); -export const AccountInputs = /* @__PURE__ */ _reexport("AccountInputs"); export const AccountInterface = /* @__PURE__ */ _reexport("AccountInterface"); export const AccountPatch = /* @__PURE__ */ _reexport("AccountPatch"); export const AccountProof = /* @__PURE__ */ _reexport("AccountProof"); @@ -211,11 +208,6 @@ export const FetchedAccount = /* @__PURE__ */ _reexport("FetchedAccount"); export const FetchedNote = /* @__PURE__ */ _reexport("FetchedNote"); export const ForeignAccount = /* @__PURE__ */ _reexport("ForeignAccount"); export const FungibleAsset = /* @__PURE__ */ _reexport("FungibleAsset"); -export const FungibleAssetDelta = - /* @__PURE__ */ _reexport("FungibleAssetDelta"); -export const FungibleAssetDeltaItem = /* @__PURE__ */ _reexport( - "FungibleAssetDeltaItem" -); export const GetProceduresResultItem = /* @__PURE__ */ _reexport( "GetProceduresResultItem" ); diff --git a/crates/web-client/js/node/client-factory.js b/crates/web-client/js/node/client-factory.js index 65c4ebce..7165cb68 100644 --- a/crates/web-client/js/node/client-factory.js +++ b/crates/web-client/js/node/client-factory.js @@ -40,8 +40,8 @@ function normBytes(val) { * Creates the WasmWebClient factory for Node.js. * * Matches the browser interface: - * WasmWebClient.createClient(rpcUrl, noteTransportUrl, seed, storeName) - * WasmWebClient.createClientWithExternalKeystore(rpcUrl, noteTransportUrl, seed, storeName, getKey, insertKey, sign) + * WasmWebClient.createClient(rpcUrl, noteTransportUrl, seed, storeName, logLevel, useWorker, observability, feeFaucetId) + * WasmWebClient.createClientWithExternalKeystore(rpcUrl, noteTransportUrl, seed, storeName, getKey, insertKey, sign, logLevel, useWorker, observability, feeFaucetId) * WasmWebClient.buildSwapTag(...) * * @param {object} rawSdk - The raw napi SDK module. @@ -53,7 +53,18 @@ export function createWasmWebClient(rawSdk, options) { buildSwapTag: (...args) => rawSdk.WebClient.buildSwapTag(...args.map(normalizeArg)), - createClient: async (rpcUrl, noteTransportUrl, seed, storeName) => { + // The trailing parameters exist so this matches what `MidenClient.create` + // passes the browser factory; only `feeFaucetId` reaches the native client. + createClient: async ( + rpcUrl, + noteTransportUrl, + seed, + storeName, + _logLevel, + _useWorker, + _observability, + feeFaucetId + ) => { const dir = options?.dataDir ? path.join(options.dataDir, storeName || "default") : storeName @@ -66,7 +77,8 @@ export function createWasmWebClient(rawSdk, options) { noteTransportUrl ?? null, normBytes(seed) ?? null, path.join(dir, `${storeName || "store"}.db`), - path.join(dir, "keystore") + path.join(dir, "keystore"), + feeFaucetId ?? null ); return wrapClient(client, storeName); }, diff --git a/crates/web-client/js/node/napi-compat.js b/crates/web-client/js/node/napi-compat.js index 7e90ce0c..b66283c5 100644 --- a/crates/web-client/js/node/napi-compat.js +++ b/crates/web-client/js/node/napi-compat.js @@ -223,7 +223,6 @@ function makeArrayPolyfills() { const names = [ "AccountArray", "AccountIdArray", - "AccountInputsArray", "FeltArray", "ForeignAccountArray", "NoteAndArgsArray", diff --git a/crates/web-client/js/resources/transactions.js b/crates/web-client/js/resources/transactions.js index f1b89ed1..aed5a368 100644 --- a/crates/web-client/js/resources/transactions.js +++ b/crates/web-client/js/resources/transactions.js @@ -226,9 +226,29 @@ export class TransactionsResource { // `note` valid so we can return it to the caller. const ownOutputs = new wasm.NoteArray(); ownOutputs.push(note); + // Since 0.17 the kernel prices a NetworkAccountTarget note by calling + // `estimate_note_fee` on the target, so the emitting transaction reads + // foreign state. Declaring the account pins that state at the reference + // block instead of leaving the client to resolve it lazily, which it can + // only do for a public account it can reach. + // + // Pricing also caps this transaction at 20 blocks: `estimate_note_fee` + // applies the standards' default expiration delta, and an expiration can + // only be lowered, so it must be included within 20 blocks of its + // reference block or the node rejects it as expired. + const targetAccounts = new wasm.ForeignAccountArray(); + targetAccounts.push( + wasm.ForeignAccount.public( + target.targetId(), + new wasm.AccountStorageRequirements() + ) + ); const builder = await this.#inner.feeAwareTransactionRequestBuilder(senderId); - const request = builder.withOwnOutputNotes(ownOutputs).build(); + const request = builder + .withOwnOutputNotes(ownOutputs) + .withForeignAccounts(targetAccounts) + .build(); const { txId, result } = await this.#submitOrSubmitWithProver( senderId, @@ -790,44 +810,6 @@ export class TransactionsResource { return await this.#inner.chainAnchorForRequest(request); } - /** - * Fetch the state and inclusion witness of each foreign account in - * `foreignAccounts`, anchored at `blockNum`. - * - * A `ForeignAccount.public` entry is fetched from the network, a - * `ForeignAccount.private` entry contributes its own state and only its - * inclusion proof is fetched, and a `ForeignAccount.prefetched` entry is - * returned as it was given. Declare the results back through - * `ForeignAccount.prefetched` and nothing is fetched for those accounts at - * execution time. - * - * Each witness opens against the account tree of `blockNum` alone, so the - * results are valid only for a transaction whose reference block is exactly - * `blockNum` — the anchor's block under {@link captureAnchor}, or the sync - * height at execution time otherwise. Do not sync between fetching these and - * executing; execution fails naming the account and the block. - * - * @param {ForeignAccount[]} foreignAccounts - Accounts to fetch inputs for. - * @param {number} blockNum - Block the witnesses are anchored at. - * @returns {Promise} Inputs, in the order given. - */ - async foreignAccountInputs(foreignAccounts, blockNum) { - this.#client.assertNotTerminated(); - const wasm = await this.#getWasm(); - // The WASM array constructor consumes its elements; push borrows and clones - // each account so callers can reuse their handles after fetching inputs. - const accounts = new wasm.ForeignAccountArray(); - for (const account of foreignAccounts ?? []) accounts.push(account); - const inputs = await this.#inner.getForeignAccountInputs( - accounts, - blockNum - ); - // Browser returns the typed AccountInputsArray, Node a plain JS array. - return Array.isArray(inputs) - ? inputs - : Array.from({ length: inputs.length() }, (_, i) => inputs.get(i)); - } - async submit(account, request, opts) { this.#client.assertNotTerminated(); assertAnchorValueUsable(opts); diff --git a/crates/web-client/js/standalone.js b/crates/web-client/js/standalone.js index 746e826e..a1b6a160 100644 --- a/crates/web-client/js/standalone.js +++ b/crates/web-client/js/standalone.js @@ -115,6 +115,19 @@ function buildNoteAssets(assets, wasm) { * Builds a Public custom-script note carrying a NetworkAccountTarget attachment * (does not submit). Provide exactly one of `recipient` or `script`. * + * Since 0.17 the kernel prices such a note by invoking a procedure on the target + * account, so the transaction that emits it must declare that account as a + * foreign account - on a fee-free chain too: + * + * ```js + * const targets = new ForeignAccountArray(); + * targets.push(ForeignAccount.public(targetId, new AccountStorageRequirements())); + * builder.withOwnOutputNotes(notes).withForeignAccounts(targets).build(); + * ``` + * + * `client.transactions.createNetworkNote` does this for you; this function only + * builds the note, so its caller owns that declaration. + * * @param {NetworkNoteOptions} opts * @returns {Note} */ diff --git a/crates/web-client/js/types/api-types.d.ts b/crates/web-client/js/types/api-types.d.ts index e3f6b591..85339459 100644 --- a/crates/web-client/js/types/api-types.d.ts +++ b/crates/web-client/js/types/api-types.d.ts @@ -39,7 +39,6 @@ import type { NetworkAccountTarget, AdviceInputs, FeltArray, - AccountInputs, ForeignAccount, PswapLineageRecord, } from "./crates/miden_client_web"; @@ -232,6 +231,16 @@ export interface ClientOptions { seed?: string | Uint8Array; /** Store isolation key. */ storeName?: string; + /** + * Faucet of the chain's fee asset, as a bech32 address or a hex account ID. + * + * Required for a network the SDK knows no fee faucet for. Miden 0.17 moved the fee asset out of + * the block header and into the protocol configuration, which a node does not serve over RPC + * yet: execution and note screening both resolve the configuration the reference block commits + * to, so a client that cannot build one cannot execute at all. Read it back with + * `client.feeFaucetId()`, which replaces the `BlockHeader.feeFaucetId()` of earlier versions. + */ + feeFaucetId?: string; /** Sync state on creation (default: false). */ autoSync?: boolean; /** External keystore callbacks. */ @@ -1097,6 +1106,15 @@ export interface TransactionsResource { * attachment, submits it as an own output note, and (optionally) waits for * confirmation. The submitted note satisfies `Note.isNetworkNote()`, so a * public network account will auto-consume it. + * + * Pricing the note calls `estimate_note_fee` on the target, which applies the + * standards' default expiration delta: the transaction must be included + * within 20 blocks of its reference block, about a minute at a three-second + * block interval. An expiration can only be lowered, never raised, so this + * cannot be widened. If a slow prove makes the node reject the submission as + * expired, `sync()` first and then call this again: the method does not sync, + * so calling it again on its own rebuilds against the same reference block + * and expires the same way. */ createNetworkNote(options: NetworkNoteOptions): Promise; /** @@ -1346,41 +1364,6 @@ export interface TransactionsResource { /** Execute a program (view call) and return the resulting stack output. */ executeProgram(options: ExecuteProgramOptions): Promise; - /** - * Fetch the state and inclusion witness of each foreign account, anchored at - * `blockNum`. - * - * A {@link ForeignAccount.public} entry is fetched from the network, a - * {@link ForeignAccount.private} entry contributes its own state and only its - * inclusion proof is fetched, and a {@link ForeignAccount.prefetched} entry is - * returned as it was given. Declare the results back through - * `ForeignAccount.prefetched` on a later request and nothing is fetched for - * those accounts at execution time — which is what lets a transaction pinned - * to an older block execute after the node stopped serving account state - * there. - * - * Each witness opens against the account tree of `blockNum` alone, so the - * results are valid only for a transaction whose reference block is exactly - * `blockNum` — the anchor's block when the request is executed against a - * {@link ChainAnchor}, or the sync height at execution time otherwise. Do not - * sync between fetching these and executing; execution fails naming the - * account and the block. - * - * Only the given accounts are fetched. This does not discover the accounts a - * transaction loads, such as faucets whose asset callbacks it triggers. - * - * Serialize an entry with `inputs.serialize()` to ship prefetched state to - * another client. - * - * @param foreignAccounts - Accounts to fetch inputs for. - * @param blockNum - Block the witnesses are anchored at. - * @returns The inputs, in the order given. - */ - foreignAccountInputs( - foreignAccounts: ForeignAccount[], - blockNum: number - ): Promise; - /** * List transactions, optionally filtered by status or IDs. * @@ -1712,12 +1695,56 @@ export interface KeystoreResource { // MidenClient // ════════════════════════════════════════════════════════════════ +/** + * Multisig-only overrides for {@link MidenClient.feeAwareTransactionRequestBuilder}. + * + * Each field pins a value the approvers sign over. Omit them all for the party + * creating a proposal; supply them to reproduce one without transporting the + * proposer's serialized request. + */ +export interface MultisigAuthOptions { + /** + * Expires the approvers' signatures this many blocks after the block the + * summary binds: the transaction must be included by then. Bound by the + * summary, so the executing party can neither shorten nor extend it. At + * least 1; omitted, the approval does not expire. + */ + approvalExpirationDelta?: number; + /** + * The salt the summary binds. Omitted, one is drawn fresh per build. + * + * Consumed by the call: the `Word` is moved across the WASM boundary, so a + * second call needs a freshly constructed one. Passing a spent handle is not + * an error - it arrives as if no salt were given and one is drawn, which is + * the divergence pinning the salt exists to prevent. + */ + feeConversionSalt?: Word; + /** The block the summary binds. Omitted, the store's sync height. */ + boundBlockNum?: number; +} + export declare class MidenClient { - /** Creates and initializes a new MidenClient. */ + /** + * Creates and initializes a new MidenClient. + * + * Every non-mock client must name the chain's fee faucet in + * {@link ClientOptions.feeFaucetId} while the SDK carries a default for no + * network; without it creation fails with an error saying so. + */ static create(options?: ClientOptions): Promise; - /** Creates a client preconfigured for testnet (rpc, prover, note transport, autoSync). */ + /** + * Creates a client preconfigured for testnet (rpc, prover, note transport, autoSync). + * + * Still needs {@link ClientOptions.feeFaucetId}: the preconfigured defaults + * cover the endpoints, not the chain's fee asset. + */ static createTestnet(options?: ClientOptions): Promise; - /** Creates a client preconfigured for devnet (rpc, prover, note transport, autoSync). */ + /** + * Creates a client preconfigured for devnet (rpc, prover, note transport, autoSync). + * + * Still needs {@link ClientOptions.feeFaucetId}: the preconfigured defaults + * cover the endpoints, not the chain's fee asset. + */ static createDevnet(options?: ClientOptions): Promise; /** Creates a mock client for testing. */ static createMock(options?: MockOptions): Promise; @@ -1782,6 +1809,17 @@ export declare class MidenClient { /** Terminates the underlying Web Worker. After this, all method calls throw. */ terminate(): void; + /** + * Returns the fee faucet of the protocol configuration this client + * registered at creation. + * + * Replaces `BlockHeader.feeFaucetId()`: since 0.17 the fee asset lives in the + * protocol configuration rather than the block header, so this reports the + * configuration the client registered at creation - the `feeFaucetId` option, + * or, for a mock client, the mock chain's own. + */ + feeFaucetId(): Promise; + /** Returns the identifier of the underlying store (e.g. IndexedDB database name, file path). */ storeIdentifier(): Promise; @@ -1820,26 +1858,47 @@ export declare class MidenClient { * `account` is the account that **executes** the request — the one whose * auth procedure pays the fee — not the recipient or a note's sender. * - * Safe as a drop-in: a salt is declared only when the chain charges a fee - * *and* the executing account is one that must choose its own. For every - * other account — and on any zero-fee chain — the builder comes back - * untouched and the request is byte-identical to one built from a bare - * builder. + * Safe as a drop-in: the executing account's auth component decides on its + * own, at any base fee. For every account that is not a multisig the builder + * comes back untouched and the request is byte-identical to one built from a + * bare builder. A zero base fee is not a second condition: since 0.17 a + * multisig auth procedure resolves its auth args whatever the chain charges, + * so a multisig gets them on a fee-free chain too. * - * Calling `withAuthArg` on the result clears the declared salt, and vice + * Calling `withAuthArg` on the result clears what this declared, and vice * versa: miden-client keeps the two mutually exclusive, so whichever is * called last wins rather than producing an error. * + * Do not call `withFeeConversionSalt` or `withAuthArg` on the builder this + * returns for a multisig. The two setters clear each other, so either one + * discards the three-word auth args this already set and the transaction + * aborts in the auth procedure. Pass `feeConversionSalt` in `options`. + * * @param account - The account that will execute the request. + * @param options - Multisig-only overrides; every field is defaulted when + * omitted and ignored for an account that is not a multisig. * * @example * ```js * const builder = await client.feeAwareTransactionRequestBuilder(wallet); * const request = builder.withCustomScript(script).build(); + * + * // An approval the co-signers have ~100 blocks to act on. + * const urgent = await client.feeAwareTransactionRequestBuilder(multisig, { + * approvalExpirationDelta: 100, + * }); + * + * // A co-signer rebuilding the proposal rather than receiving its bytes + * // pins both summary-binding values, or the summaries cannot match. + * const rebuilt = await client.feeAwareTransactionRequestBuilder(multisig, { + * feeConversionSalt: agreedSalt, + * boundBlockNum: agreedBlock, + * }); * ``` */ feeAwareTransactionRequestBuilder( - account: AccountRef + account: AccountRef, + options?: MultisigAuthOptions ): Promise; /** Advances the mock chain by one block. Only available on mock clients. */ diff --git a/crates/web-client/js/types/docs-entry.d.ts b/crates/web-client/js/types/docs-entry.d.ts index b21c85cd..b5481f75 100644 --- a/crates/web-client/js/types/docs-entry.d.ts +++ b/crates/web-client/js/types/docs-entry.d.ts @@ -11,7 +11,6 @@ export { AccountFile, AccountHeader, AccountId, - AccountInputs, AccountPatch, AccountStorage, AccountStoragePatch, diff --git a/crates/web-client/js/types/index.d.ts b/crates/web-client/js/types/index.d.ts index 29740680..b8a45567 100644 --- a/crates/web-client/js/types/index.d.ts +++ b/crates/web-client/js/types/index.d.ts @@ -12,6 +12,7 @@ import type { import type { GetKeyCallback, InsertKeyCallback, + MidenObservation, SignCallback, } from "./api-types"; @@ -137,6 +138,15 @@ export declare function wordToBigInt(word: Word): bigint; // Internal exports (not public API — for tests and advanced usage) // ════════════════════════════════════════════════════════════════ +/** + * @internal Observability fields `MidenClient.create` forwards to the low-level + * factories. Mirrors the pair on {@link ClientOptions}; both are construction-only. + */ +export interface ClientObservabilityOptions { + observer?: (observation: MidenObservation) => void; + observeSensitive?: boolean; +} + /** @internal Low-level WebClient wrapper. Use MidenClient instead. */ export declare class WasmWebClient extends WasmWebClientBase { static createClient( @@ -145,7 +155,9 @@ export declare class WasmWebClient extends WasmWebClientBase { seed?: Uint8Array, storeName?: string, logLevel?: LogLevel, - useWorker?: boolean + useWorker?: boolean, + observability?: ClientObservabilityOptions, + feeFaucetId?: string ): Promise; static createClientWithExternalKeystore( @@ -157,7 +169,9 @@ export declare class WasmWebClient extends WasmWebClientBase { insertKeyCb?: InsertKeyCallback, signCb?: SignCallback, logLevel?: LogLevel, - useWorker?: boolean + useWorker?: boolean, + observability?: ClientObservabilityOptions, + feeFaucetId?: string ): Promise; syncState(): Promise; diff --git a/crates/web-client/js/workers/web-client-methods-worker.js b/crates/web-client/js/workers/web-client-methods-worker.js index 101bbb31..890f3ff6 100644 --- a/crates/web-client/js/workers/web-client-methods-worker.js +++ b/crates/web-client/js/workers/web-client-methods-worker.js @@ -461,6 +461,7 @@ async function processMessage(event) { hasSignCb, logLevel, numThreads, + feeFaucetId, ] = args; const wasm = await getWasmOrThrow(); @@ -494,6 +495,7 @@ async function processMessage(event) { noteTransportUrl, seed, storeName, + feeFaucetId, hasGetKeyCb ? callbackProxies.getKey : undefined, hasInsertKeyCb ? callbackProxies.insertKey : undefined, hasSignCb ? callbackProxies.sign : undefined @@ -503,7 +505,8 @@ async function processMessage(event) { rpcUrl, noteTransportUrl, seed, - storeName + storeName, + feeFaucetId ); } diff --git a/crates/web-client/playwright.config.ts b/crates/web-client/playwright.config.ts index 3b942088..68e19e44 100644 --- a/crates/web-client/playwright.config.ts +++ b/crates/web-client/playwright.config.ts @@ -103,7 +103,6 @@ const ciShardProjects = process.env.CI "test/eager_entry.test.ts", "test/fpi.test.ts", "test/explicit_input_note.test.ts", - "test/foreign_account_inputs.test.ts", "test/compile_and_contract.test.ts", "test/package.test.ts", "test/mockchain.test.ts", diff --git a/crates/web-client/rollup.config.js b/crates/web-client/rollup.config.js index 919ee412..e5dc4740 100644 --- a/crates/web-client/rollup.config.js +++ b/crates/web-client/rollup.config.js @@ -428,17 +428,20 @@ export default [ verbose: true, extraArgs: { cargo: [...baseCargoArgs], - // Skip wasm-opt entirely in fast mode — it's the post-link - // optimization pass and accounts for ~1-2 min on its own. Empty - // args array tells the plugin to bypass it. dev mode keeps -O0 - // for the same reason. + // `extraArgs.wasmOpt` only ADDS flags; whether the pass runs at all is + // `optimize.wasmOpt` below. An empty array here therefore did not bypass + // wasm-opt in fast mode - it ran it bare, with no `--strip-dwarf`, over a + // module that still carried its DWARF, which the runner OOM-killed once + // the 0.17 module grew (`wasm-opt failed ... error code: null`). wasmOpt: fastBuild ? [] : wasmOptArgs, wasmBindgen: ["--keep-debug"], }, experimental: { typescriptDeclarationDir: `${distDir}/crates`, }, - optimize: { release: true, rustc: !devMode }, + // Fast mode skips the post-link optimization pass outright: it is 1-2 + // minutes of the build and PR CI does not ship the artifact. + optimize: { release: true, rustc: !devMode, wasmOpt: !fastBuild }, }), resolve(), commonjs(), diff --git a/crates/web-client/skills/chain-anchored-execution/SKILL.md b/crates/web-client/skills/chain-anchored-execution/SKILL.md index faa1d1bc..c0ec69b2 100644 --- a/crates/web-client/skills/chain-anchored-execution/SKILL.md +++ b/crates/web-client/skills/chain-anchored-execution/SKILL.md @@ -1,13 +1,13 @@ --- name: chain-anchored-execution -description: Rules for using ChainAnchor to pin transaction execution to a specific block, required whenever a signature is collected over a transaction summary by one party and the transaction is executed later or by another party, as in multisig proposals and offline co-signing. Use when writing or reviewing code that calls captureAnchor, preview, executeRequest or submit with an anchor, builds a request that travels between parties with withExplicitInputNote or foreignAccountInputs, uses useChainAnchor or usePreview, or when debugging summary commitments that never match between co-signers, INVALID_CHAIN_ANCHOR, OPERATION_BUSY, STALE_CLIENT, TRANSACTION_ALREADY_AUTHORIZED or FeeConversionInfoRequired. +description: Rules for using ChainAnchor to pin transaction execution to a specific block, required whenever a signature is collected over a transaction summary by one party and the transaction is executed later or by another party, as in multisig proposals and offline co-signing. Use when writing or reviewing code that calls captureAnchor, preview, executeRequest or submit with an anchor, builds a request that travels between parties with withExplicitInputNote or withForeignAccounts, uses useChainAnchor or usePreview, or when debugging summary commitments that never match between co-signers, INVALID_CHAIN_ANCHOR, OPERATION_BUSY, STALE_CLIENT, TRANSACTION_ALREADY_AUTHORIZED or FeeConversionInfoRequired. --- # Chain-Anchored Execution **Availability:** `@miden-sdk/miden-sdk` and `@miden-sdk/react` from `0.16.0-rc.3`, and in every release since. `withExplicitInputNote` (R3) and foreign-account -prefetching (R4) arrived in `0.16.1`. +prefetching, removed again in `0.17.0` (R4), arrived in `0.16.1`. This skill ships inside the package, so if you are reading it from `node_modules/@miden-sdk/miden-sdk/skills/`, the installed version has these surfaces. If a symbol is missing anyway, you are on a build older than the release @@ -120,48 +120,37 @@ from its own store or from the anchor the request executes against. Available from `0.16.1`. -### R4 - Prefetch foreign-account inputs at the anchor's block, and do not sync after +### R4 - A foreign account forces a recent anchor A transaction that calls into a foreign account fetches that account's state at execution time, from the node, at the reference block. An anchored flow executes at -an older block, and a node stops serving account state past a limited window, so a -proposal that sat awaiting signatures fails at execution, naming the account and -the block. - -Fetch the state once, at the anchor's block, and ship it with the proposal: +an older block, and a node stops serving account state past a limited window (50 +blocks at the time of writing), so a proposal that sat awaiting signatures fails at +execution, naming the account and the block. + +Until 0.16 the state could be fetched at the anchor's block and shipped with the +proposal. 0.17 removed that path along with the upstream types behind it - +`foreignAccountInputs`, `ForeignAccount.prefetched` and `AccountInputs` are gone, +and a foreign account's vault entries and storage-map keys are resolved during +execution instead. So for a request that calls into a foreign account, the anchor +must stay inside the node's account-history window: capture it close to execution, +and re-capture rather than reuse one that has aged out. ```ts -const inputs = await client.transactions.foreignAccountInputs( - [ForeignAccount.public(targetId, storageRequirements)], - anchor.blockNum() // exactly the anchor's block, not the sync height -); -ship(inputs.map((i) => i.serialize())); - -// Executor: nothing is fetched for these accounts at execution time. const request = builder - .withForeignAccounts([ - ForeignAccount.prefetched(AccountInputs.deserialize(bytes)), - ]) + .withForeignAccounts([ForeignAccount.public(targetId, storageRequirements)]) .build(); +const anchor = await client.transactions.captureAnchor(request); +// collect signatures, then execute promptly against `anchor` ``` -Three things to get right: +Two things to get right: -- **`blockNum` must be the anchor's block.** Each witness opens against the account - tree of that block alone. Passing the sync height produces inputs valid only for - an unanchored execution. -- **Do not sync between fetching and executing.** Execution fails naming the - account and the block. -- **Only the accounts you name are fetched.** This does not discover accounts the +- **Only the accounts you name are declared.** This does not discover accounts the transaction loads on its own, such as a faucet whose asset callback it triggers. - -`ForeignAccount.public(id, requirements)` is fetched from the network, -`ForeignAccount.private(account)` contributes its own state and fetches only an -inclusion proof, and `ForeignAccount.prefetched(inputs)` is returned untouched. -`AccountInputs.serialize()` / `.deserialize()` are the transport, and -`inputs.accountId()` reads back which account an entry describes. - -Available from `0.16.1`. +- **`ForeignAccount.public(id, requirements)` is fetched from the network** and + `ForeignAccount.private(account)` contributes its own state and fetches only an + inclusion proof. ### R5 - Validate an anchor that arrives from an untrusted party @@ -217,7 +206,7 @@ triggering control while `isCapturing` / `isPreviewing` is true. ### R9 - Import the class, not the type, to deserialize in React `@miden-sdk/react` re-exports `ChainAnchor` and `TransactionRequest` as **types -only**, and does not re-export `InputNote`, `ForeignAccount` or `AccountInputs` at +only**, and does not re-export `InputNote` or `ForeignAccount` at all. Calling a static such as `ChainAnchor.deserialize(bytes)` or `TransactionRequest.deserialize(bytes)` requires importing the class from `@miden-sdk/miden-sdk` directly. @@ -236,7 +225,7 @@ Map an observed symptom to its cause before proposing a fix. | `FeeConversionInfoUnsupported` naming the auth component | A salt was declared against an auth component that never reads it. Drop the salt, or use `withAuthArg` plus `extendAdviceMap` | | `ERR_FEE_CONVERSION_INFO_MISSING` aborting in the VM | A custom auth procedure reads conversion info that nothing committed. Attach it yourself with `withAuthArg` | | `preview` fails to find the account on the co-signer | Verification runs a real execution, so the account must already be in that participant's store. `accounts.getOrImport` for a public account; a private one needs its state transferred out of band (R2) | -| Anchored execution fails naming a foreign account and a block | Foreign-account state was fetched at the wrong block, or a sync landed between fetching and executing. Refetch at `anchor.blockNum()` and execute without syncing (R4) | +| Anchored execution fails naming a foreign account and a block | The anchor is older than the node's account-history window. Capture it closer to execution and re-capture an aged one (R4) | | `INVALID_CHAIN_ANCHOR` | A sync landed mid-capture and left the anchor inconsistent. **Retry**, since this is transient rather than a bug to work around | | `OPERATION_BUSY` | A capture or preview is already running. Await the previous one | | `STALE_CLIENT` | The client was swapped mid-call. Recapture on the new chain | @@ -300,7 +289,6 @@ that tolerates both shapes. ```ts captureAnchor(request: TransactionRequest): Promise -foreignAccountInputs(accounts: ForeignAccount[], blockNum: number): Promise preview({ operation: "custom", account, request, anchor? }) executeRequest(account, request, { anchor? }) @@ -310,8 +298,9 @@ submit(account, request, { anchor?, ...txOptions }) `client.feeAwareTransactionRequestBuilder(account)` returns a `TransactionRequestBuilder` that already declares a fee conversion salt where the executing account needs one. It is a safe drop-in for `new -TransactionRequestBuilder()`: on a zero-fee chain, or for an account that does not -choose its own salt, the builder comes back untouched. +TransactionRequestBuilder()`: for an account that is not a multisig the builder +comes back untouched. A zero base fee is not a second condition: since 0.17 a +multisig resolves its auth args whatever the chain charges. ### `ChainAnchor` @@ -321,7 +310,7 @@ choose its own salt, the builder comes back untouched. | `ChainAnchor.deserialize(bytes)` | `ChainAnchor` | Static; rebuild on the receiving side | | `blockNum()` | `u32` | Number of the anchored reference block | | `commitment()` | `Word` | Commitment of the anchored reference block | -| `blockHeader()` | `BlockHeader` | The anchored reference block header, which also carries `verificationBaseFee()` and `feeFaucetId()` | +| `blockHeader()` | `BlockHeader` | The anchored reference block header, which also carries `verificationBaseFee()` and `protocolConfigCommitment()`. The fee faucet moved into the protocol configuration in 0.17; read it with `client.feeFaucetId()` | | `free()` | void | Release the partial blockchain it carries | ### `TransactionSummary` @@ -341,16 +330,13 @@ choose its own salt, the builder comes back untouched. ```ts new TransactionRequestBuilder() .withExplicitInputNote(inputNote, args?) // pins authenticated vs unauthenticated - .withForeignAccounts([foreignAccount]) // prefetched entries skip execution-time fetches + .withForeignAccounts([foreignAccount]) // declared foreign accounts, read at the reference block InputNote.authenticated(note, inclusionProof) InputNote.unauthenticated(note) ForeignAccount.public(id, storageRequirements) ForeignAccount.private(account) -ForeignAccount.prefetched(accountInputs) - -AccountInputs.deserialize(bytes) // instance: serialize(), accountId() ``` ### React diff --git a/crates/web-client/skills/frontend-pitfalls/SKILL.md b/crates/web-client/skills/frontend-pitfalls/SKILL.md index 3a742acf..5afadc87 100644 --- a/crates/web-client/skills/frontend-pitfalls/SKILL.md +++ b/crates/web-client/skills/frontend-pitfalls/SKILL.md @@ -361,22 +361,21 @@ await client.notes.sendPrivateOutput({ noteId, to }); Related: `notes.fetchPrivate({ mode: "all" })` is gone. `fetchPrivate()` takes no arguments and always fetches incrementally from the stored cursor; historical notes for a newly tracked tag are backfilled by `sync()`, so after adding a tag just sync. -## FP13: Foreign-Account Inputs Are Pinned to One Block (HIGH) +## FP13: Foreign-Account State Is Read at the Reference Block (HIGH) -`client.transactions.foreignAccountInputs(accounts, blockNum)` (0.16.1) fetches each foreign account's state and inclusion witness so you can supply it instead of having it fetched at execution time. Each witness opens against the account tree of `blockNum` alone. +A foreign account's state and witness are fetched against the transaction's own reference block, and the vault entries and storage-map keys the foreign code reads are resolved during execution as per-asset and per-key witnesses. Nothing is prefetched: `foreignAccountInputs` and `ForeignAccount.prefetched` were removed in 0.17 along with the upstream types behind them. -**Do not sync between fetching these and executing.** The results are valid only for a transaction whose reference block is exactly `blockNum` - the anchor's block when executing against a `ChainAnchor`, or the sync height at execution time otherwise. Execution fails naming the account and the block. With auto-sync on a 15 s timer (FP6), a fetch-then-execute gap is easy to open by accident; capture a `ChainAnchor` or disable auto-sync across the window. +**The reference block must be one the node still serves account state for.** Nodes keep a bounded window of account history (50 blocks at the time of writing), so a transaction pinned to an older block - an anchor captured minutes earlier, say - fails naming the account and the block, and there is no longer a way to carry the state along with the request. Capture the `ChainAnchor` close to execution. ```tsx -const inputs = await client.transactions.foreignAccountInputs( - [ForeignAccount.public(id, storageRequirements)], - anchor.blockNum() // a method, not a property -); -// re-declare them so nothing is fetched at execution time -const accounts = inputs.map((i) => ForeignAccount.prefetched(i)); +const foreign = ForeignAccount.public(id, storageRequirements); +const request = builder + .withCustomScript(script) + .withForeignAccounts(new ForeignAccountArray([foreign])) + .build(); ``` -Second trap: **only the accounts you name are fetched.** This does not discover the accounts a transaction loads on its own, such as faucets whose asset callbacks it triggers. Each returned `AccountInputs` entry serializes on its own (`entry.serialize()`), which is how you ship prefetched state to another client. +Second trap: **only the accounts you name are declared.** This does not discover the accounts a transaction loads on its own, such as faucets whose asset callbacks it triggers. ## FP14: transactions.preview Rejects When Already Authorized (MEDIUM) @@ -476,7 +475,7 @@ Verify: `crates/web-client/js/eager.js`. - `FungibleAsset.withCallbacks(flag)` removed. The flag is an immutable property of the issuing faucet's account id; `FungibleAsset.callbacks()` still reports it. - `TransactionSummary.salt()` replaced by `userParams()` (the seven user-defined field elements the summary commitment binds). - `ExecutedTransaction.accountDelta()` and `TransactionStoreUpdate.accountDelta()` replaced by `accountPatch()`, exposing the absolute-valued `AccountPatch` / `AccountStoragePatch` / `AccountVaultPatch`. `TransactionSummary.accountDelta()` remains relative. `AccountStorageDelta` was removed. -- `AccountComponent.createNetworkAuth` renamed to `createNetworkAuthComponents(NoteScriptFee[], feeFaucetId)`, which returns an **array**. Add every returned component to the builder with `AccountBuilder.withComponent`. +- `AccountComponent.createNetworkAuth` renamed to `createNetworkAuthComponents(NoteScriptFee[], feeFaucetId)`, which returns an **array**. Add every returned component to the builder with `AccountBuilder.withComponent`. Since 0.17 `feeFaucetId` must be the chain's own (`client.feeFaucetId()`): the node never runs network transactions for an account whose fee asset differs from the chain's protocol configuration, and the notes sent to it silently go unconsumed. - MASM: note scripts calling `basic_wallet::add_assets_to_account` must switch to `basic_wallet::move_note_assets_to_account` (a stale script fails to compile with `undefined item 'add_assets_to_account'`). Account-component procedures now require `@account_procedure`, and transaction scripts use `@transaction_script pub proc main`. - `newConsumeTransactionRequest` is async and takes the consuming account as a second argument. `newPswapConsumeTransactionRequest` and `newPswapCancelTransactionRequest` are async too (parameters unchanged). Code going through `client.transactions.consume(...)` / `consumeAll(...)` or the `useConsume` hook is unaffected. - `TransactionProver.newLocalProver()` now produces Poseidon2 proofs, matching the client's own default prover instead of the prover crate's Blake3 default. Expect local proving to take roughly 1.6-2.6x longer. This is an alignment, not a regression - do not go hunting for a performance bug. diff --git a/crates/web-client/skills/frontend-source-guide/SKILL.md b/crates/web-client/skills/frontend-source-guide/SKILL.md index 84c353f8..5b66559a 100644 --- a/crates/web-client/skills/frontend-source-guide/SKILL.md +++ b/crates/web-client/skills/frontend-source-guide/SKILL.md @@ -164,7 +164,7 @@ Platform-native binaries consumed through `optionalDependencies`, and the native | Direct WasmWebClient usage | `src/context/MidenProvider.tsx` | useMidenClient(), runExclusive | | Multi-step workflow | `src/hooks/useWaitForCommit.ts`, `useWaitForNotes.ts` | Polling, timeout patterns | | Multisig proposal / co-signing | `src/hooks/useChainAnchor.ts`, `usePreview.ts` | ChainAnchor capture, `anchoredRequest`, TransactionSummary | -| Foreign-account (FPI) reads | `crates/web-client/js/resources/transactions.js` (`foreignAccountInputs`) | ForeignAccount.public/private/prefetched, block pinning | +| Foreign-account (FPI) reads | `crates/web-client/js/resources/transactions.js` (`executeRequest`, `submit`) | ForeignAccount.public/private, reference-block pinning | | Custom MASM component | `src/hooks/useCompile.ts`, `useExecuteProgram.ts` | `@account_procedure`, `@transaction_script pub proc main` | | Network note | `src/hooks/useCreateNetworkNote.ts` | NetworkAccountTarget, createNetworkAuthComponents | | Batch send | `src/hooks/useMultiSend.ts` | feeAwareTransactionRequestBuilder | diff --git a/crates/web-client/skills/observability/SKILL.md b/crates/web-client/skills/observability/SKILL.md index 60d63389..9e7f6375 100644 --- a/crates/web-client/skills/observability/SKILL.md +++ b/crates/web-client/skills/observability/SKILL.md @@ -35,6 +35,7 @@ interface MidenObservation { ```ts const client = await MidenClient.create({ rpcUrl: "testnet", + feeFaucetId: FEE_FAUCET, observer: (o) => metrics.record(o.op, o.outcome, Math.round(o.durationMs)), }); ``` diff --git a/crates/web-client/skills/signer-integration/SKILL.md b/crates/web-client/skills/signer-integration/SKILL.md index 7121efad..493e5f8a 100644 --- a/crates/web-client/skills/signer-integration/SKILL.md +++ b/crates/web-client/skills/signer-integration/SKILL.md @@ -143,7 +143,7 @@ The [frontend template](https://github.com/0xMiden/frontend-template) (last veri - **Provider order is INVERTED: `MidenProvider` runs OUTSIDE `MidenFiSignerProvider`** - see `src/providers.tsx`. This is the opposite of the canonical signer-outer / Miden-inner nesting at the top of this skill, and it is deliberate. In v0.15 and still in 0.16, when a signer provider is an *ancestor* of `MidenProvider`, `MidenProvider` treats it as its external keystore and does NOT create the `WebClient` until the signer connects (the init effect sees `signerIsConnected === false` and returns early before building the client). With a wallet that hasn't connected - or any environment without the extension - the app would hang on "Initializing…" and even public reads couldn't run. The template never signs *through* `MidenProvider` (it signs its only write, the counter increment, through the local `WebClient` rather than the wallet), so it runs `MidenProvider` in local-keystore mode (no signer ancestor → it initializes immediately, reads work pre-connect) and keeps `MidenFiSignerProvider` *inside*, purely for the connect button and the wallet's `requestTransaction`. `MidenFiSignerProvider` works standalone (it provides its own `WalletContext` + `SignerContext`; no `MultiSignerProvider` needed). Use this inversion only when you do not sign through `MidenProvider`; if external-keystore signing IS the goal, keep the canonical signer-outer order so `MidenProvider` picks up the signer's `signCb`/`accountConfig`. - **Wallet button uses `useMidenFiWallet()` + `WalletReadyState`** - see `src/components/AppContent.tsx`. The button gates on `wallet?.readyState` (rendering a disabled "Install MidenFi Wallet" state unless `readyState` is `Installed` or `Loadable`) so it can show install state before the extension is detected. `useSigner().connect()` would silently fall through to the adapter's `window.open(adapter.url, ...)` install fallback; gating on `readyState` avoids that path. - **The counter increment is a local two-transaction flow, not a wallet-signed tx** - see `src/hooks/useIncrementCounter.ts`. It does not use the wallet at all. It creates a throwaway local sender (`client.newWallet(...)`), publishes a plain increment note as that sender's own output note (`TransactionRequestBuilder().withOwnOutputNotes(...)`), then consumes the note *as the counter* (`await client.newConsumeTransactionRequest([note], counterAccountId)` - since 0.16 this is async and requires the consuming account, because it reads the chain's fee parameters to decide whether to attach fee conversion info; the 0.15 form `newConsumeTransactionRequest([note])` no longer compiles). Both transactions are submitted by the local `WebClient` via `submitNewTransactionWithProver(accountId, request, prover)` (remote prover), never by the wallet, so `useWaitForCommit` doesn't apply and the template polls the counter's storage map instead. This mirrors the project-template `increment_count` reference. - - **The note APIs in that hook (use as the reference):** the JS `NoteMetadata` constructor is attachment-less - `new NoteMetadata(sender, noteType, tag)`. Build the note with `new Note(new NoteAssets(), metadata, recipient)`. The increment note carries no attachment and uses tag `0`; the counter is a plain **public `NoAuth`** account, so anyone can consume the note against it with no signature. (Attachments still exist for other uses - `NoteAttachment.fromWord(scheme, word)` / `fromWords(scheme, words)`, read back via `.toWords()`, or `createNoteAttachment(...)` - but the increment does not need one. Network-execution targeting DOES exist on 0.16: a `Public` note carrying a `NetworkAccountTarget` attachment is auto-consumed by the operator. The target must be an account built from `AccountComponent.createNetworkAuthComponents`, committed on-chain at the transaction's reference block, allowlisting and pricing the note's script root; see `useCreateNetworkNote` in the React SDK. Targeting a plain wallet fails with `account procedure ... is not in the account procedure index map`.) + - **The note APIs in that hook (use as the reference):** the JS `NoteMetadata` constructor is attachment-less - `new NoteMetadata(sender, noteType, tag)`. Build the note with `new Note(new NoteAssets(), metadata, recipient)`. The increment note carries no attachment and uses tag `0`; the counter is a plain **public `NoAuth`** account, so anyone can consume the note against it with no signature. (Attachments still exist for other uses - `NoteAttachment.fromWord(scheme, word)` / `fromWords(scheme, words)`, read back via `.toWords()`, or `createNoteAttachment(...)` - but the increment does not need one. Network-execution targeting DOES exist on 0.16: a `Public` note carrying a `NetworkAccountTarget` attachment is auto-consumed by the operator. The target must be an account built from `AccountComponent.createNetworkAuthComponents` with the chain's fee faucet (`client.feeFaucetId()`), committed on-chain at the transaction's reference block, allowlisting and pricing the note's script root; see `useCreateNetworkNote` in the React SDK. A target built with any other fee faucet fails silently: the node never consumes its notes. Targeting a plain wallet fails with `account procedure ... is not in the account procedure index map`.) - **Two hard requirements (don't regress):** (1) the client runs with `useWorker: false` on `MidenProvider`. The default worker shim keeps a separate in-memory SMT forest per thread; consuming against an *imported* (not locally-created) account applies a delta transaction whose apply step looks the account up in the executing (worker) forest, which never contains the late-imported counter, so it fails with `account data wasn't found` ([web-sdk#222](https://github.com/0xMiden/web-sdk/issues/222)). One thread means one forest, which fixes it. (2) Submits go through the remote prover (`submitNewTransactionWithProver`) so the worker-less single thread only pays local execution, not minutes of local proving. The increment was verified working end-to-end on testnet on v0.15; there is no `INCREMENT_ONCHAIN_BLOCKED` flag. Re-verify against the template's current lockfile before relying on that, and note the `newConsumeTransactionRequest` signature change above. ## Unified Signer Interface @@ -492,9 +492,10 @@ import { AccountBuilder, AccountComponent, AccountStorageMode, NoteScriptFee } f // Returns AccountComponent[] - the auth component plus the components backing // its fee policy. Install ALL of them. +const feeFaucetId = await client.feeFaucetId(); // must be the chain's - see below const components = AccountComponent.createNetworkAuthComponents( [new NoteScriptFee(myNoteScript.root(), 0n)], // NoteScriptFee[] - must be non-empty - feeFaucetId, // AccountId - fees are denominated in this faucet's asset + feeFaucetId, // AccountId - the chain's fee faucet; fees are in its asset allowedTxScriptRoots // optional Word[] from TransactionScript.root() ); @@ -521,6 +522,12 @@ Four rules bite, all enforced in for *every* possible input, since a root pins code but not the submitter-controlled arguments or advice inputs. +A fifth is enforced only by the node, and silently: **the fee faucet must be the chain's own** +(`client.feeFaucetId()`). Since 0.17 the node's network-transaction builder refuses to execute for +an account whose fee asset differs from the chain's protocol configuration. The client is not told; +notes sent to the account are simply never consumed, and only the node's log names the cause. The +fee asset is fixed when the account is built, so an account built with another faucet is rebuilt. + Reuse the *same* compiled note script for the account allowlist and for the note, so the roots match. Targeting a plain wallet instead of a network account fails with `account procedure ... is not in the account procedure index map`. diff --git a/crates/web-client/skills/web-client-usage/SKILL.md b/crates/web-client/skills/web-client-usage/SKILL.md index f0b90dd9..9f9fd87a 100644 --- a/crates/web-client/skills/web-client-usage/SKILL.md +++ b/crates/web-client/skills/web-client-usage/SKILL.md @@ -22,7 +22,7 @@ The SDK exposes a top-level `MidenClient` whose state is split across typed | Resource | What it covers | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `client.accounts` | Wallets, faucets, custom contracts, listing, import/export, addresses | -| `client.transactions` | `send` / `mint` / `bridge` / `consume` / `consumeAll` / `swap` / `pswapCreate` / `pswapConsume` / `pswapCancel` / `createNetworkNote` / `execute` / `executeProgram` / `batch` / `submitBatch` / `preview` / `captureAnchor` / `executeRequest` / `submit` / `submitProven` / `foreignAccountInputs` / `list` / `waitFor` | +| `client.transactions` | `send` / `mint` / `bridge` / `consume` / `consumeAll` / `swap` / `pswapCreate` / `pswapConsume` / `pswapCancel` / `createNetworkNote` / `execute` / `executeProgram` / `batch` / `submitBatch` / `preview` / `captureAnchor` / `executeRequest` / `submit` / `submitProven` / `list` / `waitFor` | | `client.notes` | Listing, fetching, importing/exporting, private-note transport | | `client.tags` | Note-tag subscriptions | | `client.settings` | Persistent client settings | @@ -67,12 +67,20 @@ the low-level surface on every upgrade. import { MidenClient } from "@miden-sdk/miden-sdk"; // Testnet - autoSync on, testnet RPC + prover + note transport -const client = await MidenClient.createTestnet(); +const client = await MidenClient.createTestnet({ feeFaucetId: FEE_FAUCET }); // Devnet equivalent -const client = await MidenClient.createDevnet(); +const client = await MidenClient.createDevnet({ feeFaucetId: FEE_FAUCET }); ``` +`feeFaucetId` is not optional today, on any constructor but `createMock`. Since +0.17 the chain's fee asset lives in a protocol configuration the node does not +serve over RPC, and the SDK carries a per-network default for no network yet, so +a client created without it fails with an error naming the option. It is the +faucet the chain mints its fee asset from: ask whoever runs the network, or read +it from a local node's genesis. Snippets below leave it out to keep their own +point legible. + Both accept the same `ClientOptions` for overrides: ```typescript @@ -88,6 +96,7 @@ const client = await MidenClient.createTestnet({ ```typescript const client = await MidenClient.create({ rpcUrl: "https://rpc.testnet.miden.io", // string URL or "testnet"/"devnet"/"localhost"/"local" + feeFaucetId: FEE_FAUCET, // required - the chain's fee faucet, bech32 or hex noteTransportUrl: "https://transport.miden.io", storeName: "my-store", seed: new Uint8Array(32), // optional - string or Uint8Array; see below @@ -365,10 +374,14 @@ declines to attach fee conversion info to one it cannot classify: - `AccountComponent.createNetworkAuthComponents(allowedNoteScriptFees, feeFaucetId, allowedTxScriptRoots?)` builds a network account's auth. Each `new NoteScriptFee(noteScript.root(), amount)` pairs an allowlisted note script root with the fee the account charges to - consume notes running it (zero is valid). It returns an **array**; add every - element to the builder: + consume notes running it (zero is valid). `feeFaucetId` must be the chain's + own fee faucet, `client.feeFaucetId()`: the node never runs network + transactions for an account whose fee asset differs from the chain's protocol + configuration, and the client is not told - the notes just sit unconsumed. + It returns an **array**; add every element to the builder: ```typescript + const feeFaucetId = await client.feeFaucetId(); const components = AccountComponent.createNetworkAuthComponents( [new NoteScriptFee(noteScript.root(), 0n)], feeFaucetId @@ -404,9 +417,11 @@ await client.transactions.submit(wallet, request); ``` `account` is the account that **executes** the request, not the recipient. The -method is a safe drop-in: on a zero-fee chain, or for a single-sig, no-auth or -network account, the builder comes back untouched and the request is -byte-identical to one from a bare builder. +method is a safe drop-in: for a single-sig, no-auth or network account the +builder comes back untouched and the request is byte-identical to one from a +bare builder. A zero base fee is not a second condition: since 0.17 a multisig +resolves its auth args whatever the chain charges, so a multisig gets them on a +fee-free chain too. What happens if you skip it: @@ -417,7 +432,7 @@ What happens if you skip it: with `TransactionRequestBuilder.withAuthArg` plus `extendAdviceMap`. `withAuthArg` and `withFeeConversionSalt` are mutually exclusive - each setter -clears the other, so whichever is called last wins. +clears the other, so whichever is called last wins. Never call either on a builder from `feeAwareTransactionRequestBuilder` for a multisig: that builder already carries the three-word auth args, and either setter discards them, so the transaction aborts in the auth procedure. Pass `feeConversionSalt` to `feeAwareTransactionRequestBuilder` instead - and build a fresh `Word` for every call, because the parameter is moved across the WASM boundary and a spent handle arrives as "no salt given". ## Transactions @@ -532,13 +547,23 @@ Passing both, or neither, throws a descriptive error naming the two fields. `target` must genuinely be a network account: one built from `AccountComponent.createNetworkAuthComponents(...)` (see "Standard auth -components"), already committed on-chain at the transaction's reference block, -whose allowlist prices the note's script root. The note is priced by calling +components") with the chain's fee faucet, already committed on-chain at the +transaction's reference block, whose allowlist prices the note's script root. +The fee faucet requirement fails silently: the note is emitted, and the node +simply never consumes it. The note is priced by calling `estimate_note_fee` on the target even on a chain that charges no fees, so targeting a plain wallet fails with `account procedure ... is not in the account procedure index map`, and targeting an account that has not been committed yet fails to resolve the account at all. +That pricing call also caps the transaction: `estimate_note_fee` applies the +standards' default expiration delta, so the emitting transaction must be +included within **20 blocks** of its reference block, roughly a minute at a +three-second block interval. An expiration can only be lowered, never raised, +so neither the SDK nor the caller can widen it. If proving is slow enough that +the node rejects the submission as expired, re-execute against a fresh +reference block and submit again. + ### Consume ```typescript @@ -627,8 +652,8 @@ await client.transactions.execute({ The resource maps both the bare-ref form and the `{ id, storage }` wrapper through `ForeignAccount.public(...)`, which rejects a non-public account id with `InvalidForeignAccountId`. The wrapper supplies storage requirements; it does -not make the account private. For a private foreign account, or for prefetched -state, build the request yourself (see below) and submit it with +not make the account private. For a private foreign account, build the request +yourself (see below) and submit it with `transactions.submit`. The same applies to `transactions.executeProgram`. ### Execute a program (read-only view call) @@ -649,35 +674,27 @@ computes. ### Foreign accounts (FPI) -`ForeignAccount` has three constructors: +`ForeignAccount` has two constructors: - `ForeignAccount.public(accountId, storageRequirements)` - state is fetched from the network at execution time. - `ForeignAccount.private(account)` - you supply the account's state; only its inclusion proof is fetched. -- `ForeignAccount.prefetched(accountInputs)` - nothing is fetched at all. -Fetch the inputs up front with -`client.transactions.foreignAccountInputs(accounts, blockNum)`, which returns -an `AccountInputs[]` in the order given. Each witness opens against the account -tree of `blockNum` alone, so the results are valid only for a transaction whose -reference block is exactly `blockNum` (the anchor's block when executing against -a `ChainAnchor`, the sync height otherwise). Do not sync between fetching and -executing. `AccountInputs.serialize()` / `AccountInputs.deserialize(bytes)` -ships prefetched state to another client. +The account's state and witness are read against the transaction's reference +block, and the vault entries and storage-map keys the foreign code touches are +resolved during execution as per-asset and per-key witnesses rather than up +front. Pin the transaction to a block the node still serves account state for; +prefetching the state to execute against an older block is no longer possible +(`foreignAccountInputs` and `ForeignAccount.prefetched` were removed in 0.17). ```typescript const foreign = ForeignAccount.public(foreignAccountId, storageRequirements); -const blockNum = await client.getSyncHeight(); -const [inputs] = await client.transactions.foreignAccountInputs( - [foreign], - blockNum -); const builder = await client.feeAwareTransactionRequestBuilder(account); const request = builder .withCustomScript(script) - .withForeignAccounts(new ForeignAccountArray([ForeignAccount.prefetched(inputs)])) + .withForeignAccounts(new ForeignAccountArray([foreign])) .build(); await client.transactions.submit(account, request); ``` @@ -1131,7 +1148,7 @@ while (true) { rejects with `TRANSACTION_ALREADY_AUTHORIZED` unless authorization is pending. 8. **Passing a private account id in `execute({ foreignAccounts })`.** Every entry becomes a public foreign account; build the request yourself with - `ForeignAccount.private` / `.prefetched`. + `ForeignAccount.private`. 9. **`transactions.list({ expiredBefore })`.** The filter was removed and the query now throws. Use `{ status: "uncommitted" }`. 10. **Passing a low-level `AccountId`-only WASM method a raw string** - resource diff --git a/crates/web-client/src/lib.rs b/crates/web-client/src/lib.rs index bbd02362..9836c436 100644 --- a/crates/web-client/src/lib.rs +++ b/crates/web-client/src/lib.rs @@ -20,12 +20,15 @@ use idxdb_store::IdxdbStore; use js_export_macro::js_export; #[cfg(feature = "browser")] use js_sys::{Function, Reflect}; +use miden_client::account::AccountId as NativeAccountId; +use miden_client::asset::AssetId; use miden_client::builder::{ClientBuilder, DEFAULT_GRPC_TIMEOUT_MS}; use miden_client::crypto::RandomCoin; #[cfg(feature = "nodejs")] use miden_client::keystore::FilesystemKeyStore; use miden_client::note_transport::NoteTransportClient; use miden_client::note_transport::grpc::GrpcNoteTransportClient; +use miden_client::protocol_config::ProtocolConfig; use miden_client::rpc::{Endpoint, GrpcClient, NodeRpcClient, VerifyingRpcClient}; use miden_client::store::Store; use miden_client::testing::mock::MockRpcApi; @@ -220,6 +223,9 @@ pub fn setup_logging(log_level: &str) { #[js_export] pub struct WebClient { inner: AsyncCell>>, + /// Faucet of the fee asset the registered protocol configuration names. Since 0.17 the block + /// header no longer carries it, so this is the only place a consumer can read it back from. + fee_faucet: AsyncCell>, mock_rpc_api: AsyncCell>>, mock_note_transport_api: AsyncCell>>, } @@ -267,11 +273,25 @@ impl WebClient { WebClient { inner: AsyncCell::new(None), + fee_faucet: AsyncCell::new(None), mock_rpc_api: AsyncCell::new(None), mock_note_transport_api: AsyncCell::new(None), } } + /// Returns the fee faucet of the protocol configuration this client registered at creation. + /// + /// Before 0.17 any block header carried it, so a consumer could discover the chain's native + /// asset by reading one. The header no longer does: the fee asset lives in the protocol + /// configuration, which the node does not serve over RPC yet, so this reports the + /// configuration the client registered when it was created - the caller's `feeFaucetId`, the + /// one this SDK knows for the network, or, for a mock client, the one the mock chain itself + /// commits to. `undefined` only on a client that has not been created yet. + #[js_export(js_name = "feeFaucetId")] + pub async fn fee_faucet_id(&self) -> Option { + (*self.fee_faucet.lock().await).map(Into::into) + } + /// Returns the identifier of the underlying store (e.g. `IndexedDB` database name, file path). #[js_export(js_name = "storeIdentifier")] pub async fn store_identifier(&self) -> Result { @@ -369,6 +389,9 @@ impl WebClient { /// * `store_name`: Optional name for the web store. If `None`, the store name defaults to /// `MidenClientDB_{network_id}`, where `network_id` is derived from the `node_url`. /// Explicitly setting this allows for creating multiple isolated clients. + /// * `fee_faucet_id`: Optional fee faucet of the chain, as a bech32 address or a hex account + /// ID. Required for a network this SDK knows no fee faucet for, since a client cannot execute + /// without the protocol configuration built from it. #[wasm_bindgen(js_name = "createClient")] pub async fn create_client( &self, @@ -376,6 +399,7 @@ impl WebClient { node_note_transport_url: Option, seed: Option>, store_name: Option, + fee_faucet_id: Option, ) -> Result { let endpoint = node_url.map_or(Ok(Endpoint::testnet()), |url| { Endpoint::try_from(url.as_str()).map_err(|_| JsValue::from_str("Invalid node URL")) @@ -400,8 +424,17 @@ impl WebClient { ); let keystore = WebKeyStore::new_with_callbacks(rng, store_name.clone(), None, None, None); - self.setup_client(web_rpc_client, store, keystore, rng, note_transport_client) - .await?; + let protocol_config = resolve_protocol_config(&endpoint, fee_faucet_id)?; + + self.setup_client( + web_rpc_client, + store, + keystore, + rng, + note_transport_client, + protocol_config, + ) + .await?; Ok(JsValue::from_str("Client created successfully")) } @@ -418,6 +451,9 @@ impl WebClient { /// * `store_name`: Optional name for the web store. If `None`, the store name defaults to /// `MidenClientDB_{network_id}`, where `network_id` is derived from the `node_url`. /// Explicitly setting this allows for creating multiple isolated clients. + /// * `fee_faucet_id`: Optional fee faucet of the chain, as a bech32 address or a hex account + /// ID. Required for a network this SDK knows no fee faucet for, since a client cannot execute + /// without the protocol configuration built from it. /// * `get_key_cb`: Callback to retrieve the secret key bytes for a given public key. /// * `insert_key_cb`: Callback to persist a secret key. /// * `sign_cb`: Callback to produce serialized signature bytes for the provided inputs. @@ -429,6 +465,7 @@ impl WebClient { node_note_transport_url: Option, seed: Option>, store_name: Option, + fee_faucet_id: Option, get_key_cb: Option, insert_key_cb: Option, sign_cb: Option, @@ -457,12 +494,22 @@ impl WebClient { let keystore = WebKeyStore::new_with_callbacks(rng, store_name, get_key_cb, insert_key_cb, sign_cb); - self.setup_client(web_rpc_client, store, keystore, rng, note_transport_client) - .await?; + let protocol_config = resolve_protocol_config(&endpoint, fee_faucet_id)?; + + self.setup_client( + web_rpc_client, + store, + keystore, + rng, + note_transport_client, + protocol_config, + ) + .await?; Ok(JsValue::from_str("Client created successfully")) } + #[allow(clippy::too_many_arguments)] async fn setup_client( &self, rpc_client: Arc, @@ -470,6 +517,7 @@ impl WebClient { keystore: WebKeyStore, rng: RandomCoin, note_transport_client: Option>, + protocol_config: ProtocolConfig, ) -> Result<(), JsValue> { let mut builder = ClientBuilder::new() .rpc(rpc_client) @@ -481,6 +529,9 @@ impl WebClient { builder = builder.note_transport(transport); } + let fee_faucet = protocol_config.fee_asset_id().faucet_id(); + builder = builder.protocol_config(protocol_config); + let mut client = builder .build() .await @@ -491,6 +542,9 @@ impl WebClient { .await .map_err(|err| js_error_with_context(err, "Failed to ensure genesis in place"))?; + // Published together with `inner`, so a creation that fails leaves neither set: the + // accessor reports the faucet of a client that exists, or nothing. + *self.fee_faucet.lock().await = Some(fee_faucet); *self.inner.lock().await = Some(client); Ok(()) @@ -512,6 +566,9 @@ impl WebClient { /// reproducible too. Any other length is rejected. /// * `db_path`: Path to the SQLite database file. /// * `keystore_path`: Path to the directory for storing keys. + /// * `fee_faucet_id`: Optional fee faucet of the chain, as a bech32 address or a hex account + /// ID. Required for a network this SDK knows no fee faucet for, since a client cannot execute + /// without the protocol configuration built from it. #[napi(js_name = "createClient")] pub async fn create_client( &self, @@ -520,6 +577,7 @@ impl WebClient { seed: Option>, db_path: String, keystore_path: String, + fee_faucet_id: Option, ) -> Result { let endpoint = node_url.map_or(Ok(Endpoint::testnet()), |url| { Endpoint::try_from(url.as_str()).map_err(|_| from_str_err("Invalid node URL")) @@ -546,12 +604,15 @@ impl WebClient { let keystore = FilesystemKeyStore::new(keystore_path.into()) .map_err(|e| from_str_err(&format!("Failed to initialize keystore: {e}")))?; - self.setup_client(rpc_client, store, keystore, rng, note_transport_client) + let protocol_config = resolve_protocol_config(&endpoint, fee_faucet_id)?; + + self.setup_client(rpc_client, store, keystore, rng, note_transport_client, protocol_config) .await?; Ok("Client created successfully".to_string()) } + #[allow(clippy::too_many_arguments)] async fn setup_client( &self, rpc_client: Arc, @@ -559,13 +620,16 @@ impl WebClient { keystore: FilesystemKeyStore, rng: RandomCoin, note_transport_client: Option>, + protocol_config: ProtocolConfig, ) -> Result<(), JsErr> { + let fee_faucet = protocol_config.fee_asset_id().faucet_id(); let client = maybe_wrap_send(async move { let mut builder = ClientBuilder::new() .rpc(rpc_client) .rng(Box::new(rng)) .store(store) - .authenticator(Arc::new(keystore)); + .authenticator(Arc::new(keystore)) + .protocol_config(protocol_config); if let Some(transport) = note_transport_client { builder = builder.note_transport(transport); @@ -585,12 +649,75 @@ impl WebClient { }) .await?; + *self.fee_faucet.lock().await = Some(fee_faucet); *self.inner.lock().await = Some(client); Ok(()) } } +// PROTOCOL CONFIGURATION +// ================================================================================================ + +/// Fee faucet of every network whose protocol configuration this SDK can build, keyed by network +/// ID. +/// +/// 0.17 moved the fee asset out of the block header and into the protocol configuration, which a +/// node does not serve over RPC yet. Execution and note screening both resolve the configuration +/// the reference block commits to, so a client that holds none cannot execute at all, and the only +/// two sources are this table and the caller's `feeFaucetId`. A network is added here once its +/// genesis names a fee faucet. +const KNOWN_FEE_FAUCETS: &[(&str, &str)] = &[]; + +/// Builds the protocol configuration the client executes under, from the caller's fee faucet or +/// the one this SDK knows for the endpoint's network. +/// +/// Errors when neither is available, rather than building a client that fails on its first +/// execution with a store error naming a commitment the caller cannot act on. +pub(crate) fn resolve_protocol_config( + endpoint: &Endpoint, + fee_faucet_id: Option, +) -> Result { + let network_id = endpoint.to_network_id().to_string(); + + let faucet_id = if let Some(id) = fee_faucet_id { + parse_fee_faucet_id(&id)? + } else { + let known = KNOWN_FEE_FAUCETS + .iter() + .find_map(|(network, faucet)| (*network == network_id).then_some(*faucet)) + .ok_or_else(|| { + from_str_err(&format!( + "no fee faucet is known for network `{network_id}`, so the protocol \ + configuration this chain executes under cannot be built: pass \ + `feeFaucetId` when creating the client. Miden 0.17 moved the fee asset \ + out of the block header into the protocol configuration, which the node \ + does not serve over RPC yet." + )) + })?; + parse_fee_faucet_id(known)? + }; + + ProtocolConfig::current(AssetId::new_fungible(faucet_id)) + .map_err(|err| js_error_with_context(err, "failed to build the protocol configuration")) +} + +/// Reads a fee faucet written either as a bech32 address or as a hex account ID, the two spellings +/// the rest of the JS surface accepts for an account. +fn parse_fee_faucet_id(id: &str) -> Result { + if let Ok(account_id) = models::account_id::AccountId::from_bech32(id.to_string()) { + return Ok((&account_id).into()); + } + + models::account_id::AccountId::from_hex(id.to_string()) + .map(|account_id| (&account_id).into()) + .map_err(|_| { + from_str_err(&format!( + "`{id}` is not a fee faucet: expected a bech32 address or a hex account ID" + )) + }) +} + pub(crate) fn create_rng(seed: Option>) -> Result { let mut rng = match seed { Some(seed_bytes) => { diff --git a/crates/web-client/src/mock.rs b/crates/web-client/src/mock.rs index 8e704ae3..0cf066d3 100644 --- a/crates/web-client/src/mock.rs +++ b/crates/web-client/src/mock.rs @@ -59,12 +59,15 @@ impl WebClient { ); let keystore = WebKeyStore::new_with_callbacks(rng, store_name, None, None, None); + // The mock chain is its own source of protocol configuration: it commits to one no + // network serves, so the client has to be given that one rather than a network's. self.setup_client( mock_rpc_api.clone(), store, keystore, rng, Some(mock_note_transport_api.clone()), + mock_rpc_api.protocol_config(), ) .await?; @@ -120,12 +123,15 @@ impl WebClient { let keystore = miden_client::keystore::FilesystemKeyStore::new(keystore_path.into()) .map_err(|e| from_str_err(&format!("Failed to initialize keystore: {e}")))?; + // The mock chain is its own source of protocol configuration: it commits to one no + // network serves, so the client has to be given that one rather than a network's. self.setup_client( mock_rpc_api.clone(), store, keystore, rng, Some(mock_note_transport_api.clone()), + mock_rpc_api.protocol_config(), ) .await?; diff --git a/crates/web-client/src/models/account_component.rs b/crates/web-client/src/models/account_component.rs index f12d14f1..9a3e8fb5 100644 --- a/crates/web-client/src/models/account_component.rs +++ b/crates/web-client/src/models/account_component.rs @@ -23,7 +23,7 @@ use miden_client::auth::{ AuthSingleSig as NativeSingleSig, PublicKeyCommitment, }; -use miden_client::note::{FeeSponsorshipNote, NetworkAccountConfigNote, NoteScriptRoot}; +use miden_client::note::NoteScriptRoot; use miden_client::transaction::{ExpirationTransactionScript, TransactionScriptRoot}; use miden_client::vm::Package as NativePackage; @@ -274,7 +274,12 @@ impl AccountComponent { /// fee the account charges to consume notes running it, so every allowlisted script is /// priced by construction. A fee of zero is valid; a script root the account does not price /// at all aborts fee estimation rather than being treated as free. Fees are denominated in - /// the fungible asset issued by `feeFaucetId`. + /// the fungible asset issued by `feeFaucetId`, which must be the chain's own fee faucet - + /// `client.feeFaucetId()`. The node's network-transaction builder refuses to execute for a + /// network account whose fee asset differs from the chain's protocol configuration, and the + /// client is not told: notes sent to such an account are simply never consumed. The fee + /// asset is fixed when the account is built, so an account built with another faucet has to + /// be rebuilt. /// /// Returns the auth component together with the components backing its fee policy. Every one /// of them must be installed on the account: pass each to `AccountBuilder.withComponent`. @@ -291,12 +296,15 @@ impl AccountComponent { let mut note_roots: BTreeSet = BTreeSet::new(); let mut fee_policy = BasicConstantFeePolicy::new(); - // `AuthNetworkAccount::new` allowlists the config and fee-sponsorship note scripts on top - // of the caller's roots. The caller cannot name those two, so price both at zero here to - // keep fee estimation from aborting on them. - fee_policy = fee_policy - .with_fee(NetworkAccountConfigNote::script_root(), AssetAmount::ZERO) - .with_fee(FeeSponsorshipNote::script_root(), AssetAmount::ZERO); + // `AuthNetworkAccount::new` allowlists its own default note scripts on top of the + // caller's roots, and an allowlisted root with no fee entry aborts fee estimation. The + // caller cannot name those defaults, so price every one of them at zero - derived from + // upstream rather than restated, because the list grows: 0.17 added the P2ID root to it, + // which a hard-coded pair would have left allowlisted and unpriced on every account this + // builds. + for root in AuthNetworkAccount::default_allowed_note_scripts() { + fee_policy = fee_policy.with_fee(root, AssetAmount::ZERO); + } for entry in &allowed_note_script_fees { let root = NoteScriptRoot::from_raw(NativeWord::from(&entry.script_root)); diff --git a/crates/web-client/src/models/account_delta/vault.rs b/crates/web-client/src/models/account_delta/vault.rs index 97921b35..12eb1f56 100644 --- a/crates/web-client/src/models/account_delta/vault.rs +++ b/crates/web-client/src/models/account_delta/vault.rs @@ -1,23 +1,15 @@ use js_export_macro::js_export; -use miden_client::account::AccountId as NativeAccountId; -use miden_client::asset::{ - AccountVaultDelta as NativeAccountVaultDelta, - AssetId, - FungibleAsset as NativeFungibleAsset, - FungibleAssetDelta as NativeFungibleAssetDelta, -}; +use miden_client::asset::{AccountVaultDelta as NativeAccountVaultDelta, Asset}; -use crate::models::account_id::AccountId; use crate::models::fungible_asset::FungibleAsset; use crate::platform::{JsBytes, JsErr}; use crate::utils::{deserialize_from_bytes, serialize_to_bytes}; /// `AccountVaultDelta` stores the difference between the initial and final account vault states. /// -/// The difference is represented as follows: -/// - `fungible`: a binary tree map of fungible asset balance changes in the account vault. -/// - `non_fungible`: a binary tree map of non-fungible assets that were added to or removed from -/// the account vault. +/// The difference is a set of whole assets added to or removed from the vault, keyed by asset ID. +/// An asset appears at most once, on one side or the other: a delta carrying the same asset ID +/// twice is rejected when it is built. #[derive(Clone)] #[js_export] pub struct AccountVaultDelta(NativeAccountVaultDelta); @@ -40,125 +32,33 @@ impl AccountVaultDelta { self.0.is_empty() } - /// Returns the fungible portion of the delta. - pub fn fungible(&self) -> FungibleAssetDelta { - self.0.fungible().into() + /// Returns the number of assets changed by this delta, added and removed together. + #[js_export(js_name = "numAssets")] + pub fn num_assets(&self) -> usize { + self.0.num_assets() } - /// Returns the fungible assets that increased. + /// Returns the fungible assets this delta adds to the vault. #[js_export(js_name = "addedFungibleAssets")] pub fn added_fungible_assets(&self) -> Vec { self.0 - .fungible() - .iter() - .filter(|&(_, &value)| value > 0) - .filter_map(|(vault_key, &diff)| { - fungible_asset_from_delta(vault_key, diff.unsigned_abs()) - }) + .added_assets() + .filter(Asset::is_fungible) + .map(|asset| asset.unwrap_fungible().into()) .collect() } - /// Returns the fungible assets that decreased. + /// Returns the fungible assets this delta removes from the vault. #[js_export(js_name = "removedFungibleAssets")] pub fn removed_fungible_assets(&self) -> Vec { self.0 - .fungible() - .iter() - .filter(|&(_, &value)| value < 0) - .filter_map(|(vault_key, &diff)| { - fungible_asset_from_delta(vault_key, diff.unsigned_abs()) - }) + .removed_assets() + .filter(Asset::is_fungible) + .map(|asset| asset.unwrap_fungible().into()) .collect() } } -/// Rebuilds a fungible asset from a vault-delta entry, preserving the vault key's callback flag. -/// -/// The callback flag is part of the asset's vault-key and value encoding, so dropping it would -/// report an asset that differs from the one the kernel encoded (e.g. for agglayer-minted assets). -fn fungible_asset_from_delta(asset_id: &AssetId, amount: u64) -> Option { - NativeFungibleAsset::new(asset_id.faucet_id(), amount).ok().map(Into::into) -} - -/// A single fungible asset change in the vault delta. -#[derive(Clone)] -#[js_export] -pub struct FungibleAssetDeltaItem { - faucet_id: AccountId, - amount: i64, -} - -#[js_export] -impl FungibleAssetDeltaItem { - /// Returns the faucet ID this delta refers to. - #[js_export(getter, js_name = "faucetId")] - pub fn faucet_id(&self) -> AccountId { - self.faucet_id - } - - /// Returns the signed amount change (positive adds assets, negative removes). - #[js_export(getter)] - pub fn amount(&self) -> i64 { - self.amount - } -} - -impl From<(&miden_client::asset::AssetId, &i64)> for FungibleAssetDeltaItem { - fn from(native_fungible_asset_delta_item: (&miden_client::asset::AssetId, &i64)) -> Self { - Self { - faucet_id: native_fungible_asset_delta_item.0.faucet_id().into(), - amount: *native_fungible_asset_delta_item.1, - } - } -} - -/// Aggregated fungible deltas keyed by faucet ID. -#[derive(Clone)] -#[js_export] -pub struct FungibleAssetDelta(NativeFungibleAssetDelta); - -#[js_export] -impl FungibleAssetDelta { - /// Serializes the fungible delta into bytes. - pub fn serialize(&self) -> JsBytes { - serialize_to_bytes(&self.0) - } - - /// Deserializes a fungible delta from bytes. - pub fn deserialize(bytes: JsBytes) -> Result { - deserialize_from_bytes::(&bytes).map(FungibleAssetDelta) - } - - /// Returns true if no fungible assets are affected. - #[js_export(js_name = "isEmpty")] - pub fn is_empty(&self) -> bool { - self.0.is_empty() - } - - /// Returns the delta amount for a given faucet, if present. - /// - /// Matches by faucet id so the delta is found regardless of the asset's - /// callback flag. - pub fn amount(&self, faucet_id: &AccountId) -> Option { - let native_faucet_id: NativeAccountId = faucet_id.into(); - self.0 - .iter() - .find(|(vault_key, _)| vault_key.faucet_id() == native_faucet_id) - .map(|(_, &amount)| amount) - } - - /// Returns the number of distinct fungible assets in the delta. - #[js_export(js_name = "numAssets")] - pub fn num_assets(&self) -> usize { - self.0.num_assets() - } - - /// Returns all fungible asset deltas as a list. - pub fn assets(&self) -> Vec { - self.0.iter().map(Into::into).collect() - } -} - // CONVERSIONS // ================================================================================================ @@ -185,41 +85,3 @@ impl From<&AccountVaultDelta> for NativeAccountVaultDelta { account_vault_delta.0.clone() } } - -impl From for FungibleAssetDelta { - fn from(native_fungible_asset_delta: NativeFungibleAssetDelta) -> Self { - Self(native_fungible_asset_delta) - } -} - -impl From<&NativeFungibleAssetDelta> for FungibleAssetDelta { - fn from(native_fungible_asset_delta: &NativeFungibleAssetDelta) -> Self { - Self(native_fungible_asset_delta.clone()) - } -} - -impl From for NativeFungibleAssetDelta { - fn from(fungible_asset_delta: FungibleAssetDelta) -> Self { - fungible_asset_delta.0 - } -} - -impl From<&FungibleAssetDelta> for NativeFungibleAssetDelta { - fn from(fungible_asset_delta: &FungibleAssetDelta) -> Self { - fungible_asset_delta.0.clone() - } -} - -#[cfg(test)] -mod tests { - #[test] - fn fungible_delta_sign_classification_excludes_zero() { - let deltas = [10_i64, 0_i64, -5_i64]; - - let added: Vec = deltas.iter().copied().filter(|&v| v > 0).collect(); - let removed: Vec = deltas.iter().copied().filter(|&v| v < 0).collect(); - - assert_eq!(added, vec![10]); - assert_eq!(removed, vec![-5]); - } -} diff --git a/crates/web-client/src/models/account_inputs.rs b/crates/web-client/src/models/account_inputs.rs deleted file mode 100644 index 5e38c71e..00000000 --- a/crates/web-client/src/models/account_inputs.rs +++ /dev/null @@ -1,59 +0,0 @@ -use js_export_macro::js_export; -use miden_client::transaction::AccountInputs as NativeAccountInputs; - -use super::account_id::AccountId; -use crate::platform::{JsBytes, JsErr}; -use crate::utils::{deserialize_from_bytes, serialize_to_bytes}; - -/// State and inclusion witness of a foreign account, fetched at a specific block. -#[derive(Clone)] -#[js_export] -pub struct AccountInputs(NativeAccountInputs); - -#[js_export] -impl AccountInputs { - /// Returns the ID of the account these inputs describe. - #[js_export(js_name = "accountId")] - pub fn account_id(&self) -> AccountId { - self.0.id().into() - } - - /// Serializes the account inputs into bytes. - pub fn serialize(&self) -> JsBytes { - serialize_to_bytes(&self.0) - } - - /// Restores account inputs from their serialized bytes. - pub fn deserialize(bytes: JsBytes) -> Result { - deserialize_from_bytes::(&bytes).map(AccountInputs) - } -} - -// CONVERSIONS -// ================================================================================================ - -impl From for AccountInputs { - fn from(account_inputs: NativeAccountInputs) -> Self { - AccountInputs(account_inputs) - } -} - -impl From<&NativeAccountInputs> for AccountInputs { - fn from(account_inputs: &NativeAccountInputs) -> Self { - AccountInputs(account_inputs.clone()) - } -} - -impl From for NativeAccountInputs { - fn from(account_inputs: AccountInputs) -> Self { - account_inputs.0 - } -} - -impl From<&AccountInputs> for NativeAccountInputs { - fn from(account_inputs: &AccountInputs) -> Self { - account_inputs.0.clone() - } -} - -impl_napi_from_value!(AccountInputs); diff --git a/crates/web-client/src/models/account_proof.rs b/crates/web-client/src/models/account_proof.rs index 7b9ef694..b6e22e9e 100644 --- a/crates/web-client/src/models/account_proof.rs +++ b/crates/web-client/src/models/account_proof.rs @@ -4,7 +4,7 @@ use alloc::vec::Vec; use js_export_macro::js_export; use miden_client::Word as NativeWord; use miden_client::account::StorageSlotName; -use miden_client::asset::{AccountStorageHeader, Asset as NativeAsset}; +use miden_client::asset::AccountStorageHeader; use miden_client::block::BlockNumber; use miden_client::rpc::domain::account::{ AccountProof as NativeAccountProof, @@ -179,10 +179,8 @@ impl AccountProof { self.inner.vault_details().map(|d| { d.assets .iter() - .filter_map(|asset| match asset { - NativeAsset::Fungible(f) => Some((*f).into()), - NativeAsset::NonFungible(_) => None, - }) + .filter(|asset| asset.is_fungible()) + .map(|asset| asset.unwrap_fungible().into()) .collect() }) } diff --git a/crates/web-client/src/models/advice_inputs.rs b/crates/web-client/src/models/advice_inputs.rs index f12dc795..c13d0237 100644 --- a/crates/web-client/src/models/advice_inputs.rs +++ b/crates/web-client/src/models/advice_inputs.rs @@ -20,7 +20,7 @@ impl AdviceInputs { /// Returns the stack inputs as a vector of felts. pub fn stack(&self) -> Vec { - self.0.advice_stack().iter().map(Into::into).collect() + self.0.stack().iter().map(Into::into).collect() } /// Returns mapped values for a given key if present. @@ -28,7 +28,7 @@ impl AdviceInputs { pub fn mapped_values(&self, key: &Word) -> Option> { let native_key: miden_client::Word = key.into(); self.0 - .map + .map() .get(&native_key) .map(|arc| arc.iter().copied().map(Into::into).collect()) } diff --git a/crates/web-client/src/models/block_header.rs b/crates/web-client/src/models/block_header.rs index 1f2919d2..b35403f4 100644 --- a/crates/web-client/src/models/block_header.rs +++ b/crates/web-client/src/models/block_header.rs @@ -1,14 +1,13 @@ use js_export_macro::js_export; use miden_client::block::BlockHeader as NativeBlockHeader; -use super::account_id::AccountId; use super::word::Word; /// Public header for a block, containing commitments to the chain state and the proof attesting to /// the block's validity. /// /// Key fields include the previous block commitment, block number, chain/nullifier/note roots, -/// transaction commitments (including the kernel), proof commitment, and a timestamp. Two derived +/// the transaction commitment, the protocol configuration commitment, and a timestamp. Two derived /// values are exposed: /// - `sub_commitment`: sequential hash of all fields except the `note_root`. /// - `commitment`: a 2-to-1 hash of the `sub_commitment` and the `note_root`. @@ -19,7 +18,7 @@ pub struct BlockHeader(NativeBlockHeader); #[js_export] impl BlockHeader { /// Returns the header version. - pub fn version(&self) -> u32 { + pub fn version(&self) -> u8 { self.0.version() } @@ -76,10 +75,15 @@ impl BlockHeader { self.0.tx_commitment().into() } - /// Returns the transaction kernel commitment. - #[js_export(js_name = "txKernelCommitment")] - pub fn tx_kernel_commitment(&self) -> Word { - self.0.tx_kernel_commitment().into() + /// Returns the commitment to the protocol configuration this block was built under. + /// + /// The configuration itself carries the transaction, batch and block kernels and the fee + /// asset, which the header committed to individually before 0.17. Execution resolves the + /// configuration by this commitment, so a client that has not registered a matching one + /// cannot execute against this block. + #[js_export(js_name = "protocolConfigCommitment")] + pub fn protocol_config_commitment(&self) -> Word { + self.0.protocol_config_commitment().into() } /// Returns the block commitment, not a distinct proof commitment. @@ -97,17 +101,6 @@ impl BlockHeader { self.0.timestamp() } - /// Returns the account ID of the fungible faucet whose assets are accepted as the native - /// asset of the blockchain (i.e. the asset used for paying transaction verification fees). - /// - /// This is stored on-chain as part of the block's fee parameters, which means consumers can - /// discover the native faucet by reading any block header rather than hardcoding it per - /// network. - #[js_export(js_name = "feeFaucetId")] - pub fn fee_faucet_id(&self) -> AccountId { - self.0.fee_parameters().fee_faucet_id().into() - } - /// Returns the chain's verification base fee, in the fee asset's smallest unit. /// /// This is a rate, not the amount a transaction pays: the fee charged is the base fee times diff --git a/crates/web-client/src/models/foreign_account.rs b/crates/web-client/src/models/foreign_account.rs index 07039c30..e7efd6fe 100644 --- a/crates/web-client/src/models/foreign_account.rs +++ b/crates/web-client/src/models/foreign_account.rs @@ -5,7 +5,6 @@ use miden_client::transaction::ForeignAccount as NativeForeignAccount; use crate::js_error_with_context; use crate::models::account::Account; use crate::models::account_id::AccountId; -use crate::models::account_inputs::AccountInputs; use crate::models::account_storage_requirements::AccountStorageRequirements; use crate::platform::JsErr; @@ -37,12 +36,6 @@ impl ForeignAccount { Ok(ForeignAccount(native_foreign_account)) } - /// Creates a foreign account entry from already fetched inputs, valid only for a transaction - /// whose reference block is the block they were fetched at. - pub fn prefetched(inputs: &AccountInputs) -> ForeignAccount { - ForeignAccount(NativeForeignAccount::Prefetched(inputs.into())) - } - /// Returns the required storage slots/keys for this foreign account. #[js_export(js_name = "storageSlotRequirements")] pub fn storage_slot_requirements(&self) -> AccountStorageRequirements { diff --git a/crates/web-client/src/models/mod.rs b/crates/web-client/src/models/mod.rs index 4ddfbea7..eb4b09ca 100644 --- a/crates/web-client/src/models/mod.rs +++ b/crates/web-client/src/models/mod.rs @@ -36,7 +36,6 @@ pub mod account_delta; pub mod account_file; pub mod account_header; pub mod account_id; -pub mod account_inputs; pub mod account_patch; pub mod account_proof; pub mod account_reader; @@ -141,7 +140,6 @@ declare_js_miden_arrays! { (crate::models::storage_slot::StorageSlot) -> StorageSlotArray, (crate::models::transaction_script_inputs::TransactionScriptInputPair) -> TransactionScriptInputPairArray, (crate::models::felt::Felt) -> FeltArray, - (crate::models::account_inputs::AccountInputs) -> AccountInputsArray, (crate::models::transaction_request::note_and_args::NoteAndArgs) -> NoteAndArgsArray, (crate::models::transaction_request::note_details_and_tag::NoteDetailsAndTag) -> NoteDetailsAndTagArray, (crate::models::transaction_request::note_id_and_args::NoteIdAndArgs) -> NoteIdAndArgsArray diff --git a/crates/web-client/src/models/transaction_request/transaction_request_builder.rs b/crates/web-client/src/models/transaction_request/transaction_request_builder.rs index 5c6bf58d..8a97fda4 100644 --- a/crates/web-client/src/models/transaction_request/transaction_request_builder.rs +++ b/crates/web-client/src/models/transaction_request/transaction_request_builder.rs @@ -166,6 +166,12 @@ impl TransactionRequestBuilder { /// miden-client rather than reported as an error: each setter clears the other, so whichever /// is called last wins and the request can never carry both. /// + /// That symmetry makes this setter destructive in the same place its twin is: on a builder + /// returned by `feeAwareTransactionRequestBuilder` for a multisig account, which already + /// carries the component's three-word auth args. Calling this discards them and the auth + /// procedure aborts piping a preimage that was never written. Pass `feeConversionSalt` to + /// `feeAwareTransactionRequestBuilder` instead. + /// /// Setting this opts the request out of the client's fee-conversion machinery entirely. The /// client commits conversion info only when the request carries no auth argument of its own, /// so a caller that sets one is taking responsibility for the fee: on a fee-charging chain @@ -196,7 +202,11 @@ impl TransactionRequestBuilder { /// Declaring a salt against an account whose auth component does not read the auth args as /// conversion info is refused with `FeeConversionInfoUnsupported`. /// - /// Mutually exclusive with `withAuthArg` — see the note there. + /// Mutually exclusive with `withAuthArg` — see the note there. That makes this setter + /// destructive on a builder returned by `feeAwareTransactionRequestBuilder` for a multisig: + /// that builder already carries the component's three-word auth args, and clearing them + /// leaves the auth procedure piping a preimage that was never written. Pass + /// `feeConversionSalt` to `feeAwareTransactionRequestBuilder` instead. #[js_export(js_name = "withFeeConversionSalt")] pub fn with_fee_conversion_salt(&mut self, salt: &Word) -> Self { let native_salt: NativeWord = salt.into(); diff --git a/crates/web-client/src/new_transactions.rs b/crates/web-client/src/new_transactions.rs index 3f1de7bc..04722285 100644 --- a/crates/web-client/src/new_transactions.rs +++ b/crates/web-client/src/new_transactions.rs @@ -1,6 +1,8 @@ use alloc::collections::BTreeMap; +use core::num::NonZeroU32; use js_export_macro::js_export; +use miden_client::account::standards::auth::{FeeConversionInfo, MultisigAuthArgs}; use miden_client::account::{AccountComponentInterfaceExt, AccountId as NativeAccountId}; use miden_client::agglayer::B2AggNote; use miden_client::asset::{AssetAmount, FungibleAsset}; @@ -26,16 +28,16 @@ use miden_client::transaction::{ TransactionRequestBuilder as NativeTransactionRequestBuilder, }; use miden_client::{Client, ClientError, Word as NativeWord}; +use miden_protocol::crypto::SequentialCommit; use crate::models::NoteType; use crate::models::account_id::AccountId; -use crate::models::account_inputs::AccountInputs; use crate::models::advice_inputs::AdviceInputs; use crate::models::chain_anchor::ChainAnchor; use crate::models::eth_address::EthAddress; use crate::models::felt::Felt; use crate::models::foreign_account::ForeignAccount; -use crate::models::miden_arrays::{AccountInputsArray, FeltArray, ForeignAccountArray}; +use crate::models::miden_arrays::{FeltArray, ForeignAccountArray}; use crate::models::note::Note; use crate::models::proven_transaction::ProvenTransaction; use crate::models::provers::TransactionProver; @@ -46,6 +48,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::models::word::Word; use crate::platform::{ JsBytes, JsErr, @@ -671,8 +674,7 @@ impl WebClient { ) -> Result { let mut guard = self.get_mut_inner().await; let client = guard.as_mut().ok_or_else(|| from_str_err("Client not initialized"))?; - let foreign_accounts_vec: Vec = - foreign_accounts.into(); + let foreign_accounts_vec: Vec = foreign_accounts.into(); let foreign_accounts_map: BTreeMap = foreign_accounts_vec .into_iter() @@ -696,29 +698,6 @@ impl WebClient { Ok(felt_vec.into()) } - /// Fetches the state and inclusion witness of each foreign account, anchored at `block_num`. - #[js_export(js_name = "getForeignAccountInputs")] - pub async fn get_foreign_account_inputs( - &self, - foreign_accounts: ForeignAccountArray, - block_num: u32, - ) -> Result { - let mut guard = self.get_mut_inner().await; - let client = guard.as_mut().ok_or_else(|| from_str_err("Client not initialized"))?; - - let foreign_accounts_vec: Vec = foreign_accounts.into(); - let native_foreign_accounts: Vec = - foreign_accounts_vec.into_iter().map(Into::into).collect(); - - let account_inputs = client - .get_foreign_account_inputs(native_foreign_accounts, BlockNumber::from(block_num)) - .await - .map_err(|err| js_error_with_context(err, "failed to get foreign account inputs"))?; - - let inputs_vec: Vec = account_inputs.into_iter().map(Into::into).collect(); - Ok(inputs_vec.into()) - } - /// Generates a transaction proof using either the provided prover or the client's default /// prover if none is supplied. /// @@ -858,19 +837,41 @@ impl WebClient { /// /// For every other account this returns an untouched builder, so it is a safe drop-in: fees /// are settled in the chain's native fee asset at rate 1/1 and miden-client commits that - /// itself, under a fixed default salt, without anything being declared here. It is also a - /// no-op on a zero-fee chain. + /// itself, under a fixed default salt, without anything being declared here. + /// + /// Three optional values let a caller pin what the approvers sign over, all multisig-only + /// and all defaulted when left out. + /// + /// `approval_expiration_delta` expires the approvers' signatures `delta` blocks after the + /// block the summary binds: the transaction must then be included by `bound_block + delta` + /// or it can no longer be executed. It is bound by the summary, so neither the executing + /// party nor a relay can shorten or extend it. Left out, the approval never expires. + /// + /// `fee_conversion_salt` and `bound_block_num` are what a co-signer needs to REPRODUCE a + /// proposal rather than receive it. Left out, the salt is drawn fresh and the block is the + /// store's sync height, which is right for the party creating the proposal and wrong for + /// anyone rebuilding it: both are bound by the summary, so two parties who disagree on + /// either can never derive the same one. A co-signer who has the proposer's serialized + /// request does not need these - it carries the auth argument and its advice-map preimage. #[js_export(js_name = "feeAwareTransactionRequestBuilder")] pub async fn fee_aware_transaction_request_builder( &self, account_id: &AccountId, + approval_expiration_delta: Option, + fee_conversion_salt: Option, + bound_block_num: Option, ) -> Result { let mut guard = self.get_mut_inner().await; let client = guard.as_mut().ok_or_else(|| { from_str_err("Client not initialized while creating a transaction request builder") })?; - let builder = fee_aware_builder(client, account_id.into()).await?; + let overrides = MultisigAuthOverrides { + approval_expiration_delta, + salt: fee_conversion_salt.as_ref().map(NativeWord::from), + bound_block_num: bound_block_num.map(BlockNumber::from), + }; + let builder = fee_aware_builder_with(client, account_id.into(), overrides).await?; Ok(TransactionRequestBuilder::from_native(builder)) } } @@ -990,6 +991,14 @@ async fn standard_auth_components( /// conversion info at all, and declaring a salt against it is refused upstream with /// `FeeConversionInfoUnsupported`, so those answer `false` too. /// +/// A zero base fee used to be a second gate, on the grounds that miden-client skips the whole +/// fee-conversion path when the chain charges nothing and no salt is declared. 0.17 made that +/// wrong for the components this selects: a multisig auth procedure resolves its `AUTH_ARGS` +/// unconditionally - it takes the block the summary binds and the summary salt from them, and +/// only skips *creating* the fee note when the base fee is zero. Declaring nothing on a fee-free +/// chain therefore left the account with no auth args at all, and the component aborted piping a +/// preimage that was never written ("advice stack read failed"). +/// /// Answers `false` when the account is not in the store, so the account-not-found error surfaces /// on its own rather than being preempted by a fee decision about an account nothing knows /// anything about. @@ -1018,46 +1027,6 @@ async fn requires_caller_chosen_salt( })) } -/// A fresh fee conversion salt for `executing_account_id`, or `None` where the caller should -/// declare none. -/// -/// Two gates, both of which keep a request byte-identical to what it would have been when nothing -/// needs declaring. A zero base fee is the first: miden-client skips the whole fee-conversion path -/// when the chain charges nothing AND no salt is declared, so declaring one there would start -/// committing conversion info on chains that do not want it. The second is the executing account's -/// auth component, for the reasons in `requires_caller_chosen_salt`. -/// -/// Reads the fee parameters from the store's sync height, while execution reads them from the -/// reference block — the same block only on the unanchored path, since `prepare_transaction` takes -/// the reference header from the anchor when one is supplied. So a request built at a sync height -/// whose base fee is zero and then executed against an anchor whose base fee is not carries no -/// salt, and a multisig account fails with `FeeConversionInfoRequired` at execute time — after the -/// summary has already gone out to co-signers. Build the request and take the anchor at the same -/// sync height. -async fn caller_chosen_fee_conversion_salt( - client: &mut Client, - executing_account_id: NativeAccountId, -) -> Result, JsErr> { - let header = client.get_latest_block_header().await.map_err(|err| { - js_error_with_context( - err, - &format!( - "failed to read fee parameters from the latest block header while preparing a \ - request for account {executing_account_id}" - ), - ) - })?; - if header.fee_parameters().verification_base_fee() == 0 { - return Ok(None); - } - - if !requires_caller_chosen_salt(client, executing_account_id).await? { - return Ok(None); - } - - Ok(Some(client.rng().draw_word())) -} - /// A request builder already carrying a fee conversion salt where the executing account needs one. /// /// Every convenience constructor that already holds the client starts from this rather than @@ -1077,10 +1046,120 @@ async fn caller_chosen_fee_conversion_salt( async fn fee_aware_builder( client: &mut Client, executing_account_id: NativeAccountId, +) -> Result { + fee_aware_builder_with(client, executing_account_id, MultisigAuthOverrides::default()).await +} + +/// What a caller may pin in a multisig's auth args instead of taking the SDK's default. Every +/// convenience constructor passes the default; only `feeAwareTransactionRequestBuilder` exposes +/// these, and every field is ignored for an account that is not a multisig. +#[derive(Default)] +struct MultisigAuthOverrides { + approval_expiration_delta: Option, + salt: Option, + bound_block_num: Option, +} + +/// `fee_aware_builder` with the caller's multisig overrides applied. +async fn fee_aware_builder_with( + client: &mut Client, + executing_account_id: NativeAccountId, + overrides: MultisigAuthOverrides, ) -> Result { let mut builder = NativeTransactionRequestBuilder::new(); - if let Some(salt) = caller_chosen_fee_conversion_salt(client, executing_account_id).await? { - builder = builder.fee_conversion_salt(salt); + if !requires_caller_chosen_salt(client, executing_account_id).await? { + return Ok(builder); } + + // A multisig account reads three words out of its auth args - the block the summary binds and + // its approval expiration, the salt, and the fee conversion info - while miden-client's own + // `fee_conversion_salt` path commits the two-word fee pair that a fixed-salt component reads. + // Handing a multisig the shorter preimage makes its auth procedure abort while piping it + // ("advice stack read failed"), so build the multisig shape here and set it as the auth arg; + // miden-client leaves a request that already carries one alone. + let auth_args = multisig_auth_args(client, overrides).await?; + let commitment = auth_args.to_commitment(); + builder = builder + .auth_arg(commitment) + .extend_advice_map([(commitment, auth_args.to_elements())]); Ok(builder) } + +/// The multisig auth args for a request built now: bound to the store's sync height, carrying the +/// caller's salt and the chain's fee conversion info. +/// +/// The bound block is what the approvers sign over, and the kernel requires it at or before the +/// transaction's reference block. A request executed against a [`ChainAnchor`] therefore has to be +/// built and anchored at the same sync height - capture the anchor for the request as soon as it +/// is built, before the chain advances. +/// +/// The approval does not expire unless `approval_expiration_delta` asks for one. +async fn multisig_auth_args( + client: &mut Client, + overrides: MultisigAuthOverrides, +) -> Result { + // One read, not two. `get_latest_block_header` itself begins with a sync-height read, so + // taking the bound block from a separate `get_sync_height` call read it twice and let a sync + // landing in between bind the summary to one block while reading the fee asset from another. + // The fee asset comes from the protocol configuration the latest header commits to, which is + // the one execution resolves. When the caller pins an older bound block the two are read from + // different blocks; that cannot differ today, because a client registers exactly one + // configuration and the node serves none, but this is the assumption it rests on. + let header = client.get_latest_block_header().await.map_err(|err| { + js_error_with_context(err, "failed to read the latest block header for the auth args") + })?; + let bound_block_num = overrides.bound_block_num.unwrap_or_else(|| header.block_num()); + let protocol_config = client + .get_protocol_config(header.protocol_config_commitment()) + .await + .map_err(|err| { + js_error_with_context(err, "failed to read the registered protocol configuration") + })?; + + // Validate the expiration BEFORE drawing, so no fallible step sits between the draw and the + // return. `with_approval_expiration_delta` is fallible, and it can only run on an args value, + // which needs a salt - so it is exercised here against a throwaway one. + let expiration = match overrides.approval_expiration_delta { + // Zero would mean "expired at the block it was approved at", which the kernel rejects + // rather than reading as no expiration; refuse it where the caller can see why. + Some(0) => { + return Err(from_str_err(concat!( + "approvalExpirationDelta must be at least 1 block; ", + "omit it for an approval that does not expire", + ))); + }, + Some(delta) => { + let delta = NonZeroU32::new(delta).expect("zero is rejected above"); + // Validate through the upstream setter rather than re-deriving its bound here: a + // throwaway args value proves the real call below cannot fail for this input, and + // nothing local has to stay in step with what upstream rejects. + MultisigAuthArgs::new(bound_block_num, NativeWord::default()) + .with_approval_expiration_delta(delta) + .map_err(|err| { + js_error_with_context(err, "failed to set the multisig approval expiration") + })?; + Some(delta) + }, + None => None, + }; + + // Everything that can fail has run. Draw only now, and only when the caller pinned nothing: + // drawing and discarding would advance the client RNG, and `seed` documents that stream as + // reproducible, so a build that errored would shift every later draw relative to one that + // did not. + let salt = match overrides.salt { + Some(salt) => salt, + None => client.rng().draw_word(), + }; + + let auth_args = MultisigAuthArgs::new(bound_block_num, salt).with_conversion_info( + FeeConversionInfo::one_to_one(protocol_config.fee_asset_id().faucet_id()), + ); + + match expiration { + Some(delta) => auth_args.with_approval_expiration_delta(delta).map_err(|err| { + js_error_with_context(err, "failed to set the multisig approval expiration") + }), + None => Ok(auth_args), + } +} diff --git a/crates/web-client/test/fee_conversion_salt.test.ts b/crates/web-client/test/fee_conversion_salt.test.ts index e7f212d8..a2e90a90 100644 --- a/crates/web-client/test/fee_conversion_salt.test.ts +++ b/crates/web-client/test/fee_conversion_salt.test.ts @@ -27,6 +27,120 @@ import { test, expect } from "./test-setup"; // header actually reports rather than the zero the mock chain happens to use, // so the file stays correct if the mock chain is ever changed to charge. +test.describe("multisig auth args", () => { + // The auth args are what the approvers sign over, so every value the SDK + // injects into them has to be pinnable by a caller who is REPRODUCING a + // proposal rather than receiving one. These run against the mock chain: the + // assertions are about the request the builder produces, not execution. + test("the same salt and bound block reproduce the same auth arg", async ({ + run, + }) => { + const result = await run(async ({ client, sdk, helpers }) => { + const { multisigAccountId } = + await helpers.setupMultisigWithConsumableNote(); + // A fresh Word per call, every call: the salt parameter is OWNED, so + // wasm-bindgen moves the handle and a second call passes a consumed one, + // which arrives as `None` and silently draws a salt instead. Reusing one + // Word here made this very test measure nothing after its first call. + const salt = () => new sdk.Word(sdk.u64Array([1, 2, 3, 4])); + + // `run` hands the callback the RAW client, not MidenClient, so this is the + // positional export: (account, approvalExpirationDelta, feeConversionSalt, + // boundBlockNum). MidenClient's options object fails loudly in napi here + // and is silently coerced to Some(0) in wasm. + const authArgAt = async (block) => + ( + await client.feeAwareTransactionRequestBuilder( + multisigAccountId, + undefined, + salt(), + block + ) + ) + .build() + .authArg() + ?.toHex(); + + const defaulted = async () => + (await client.feeAwareTransactionRequestBuilder(multisigAccountId)) + .build() + .authArg() + ?.toHex(); + + return { + pinnedA: await authArgAt(1), + pinnedB: await authArgAt(1), + otherBlock: await authArgAt(2), + defaultedA: await defaulted(), + defaultedB: await defaulted(), + }; + }); + + // Two parties who agree on both values derive the same summary. + expect(result.pinnedA).toBeTruthy(); + expect(result.pinnedA).toBe(result.pinnedB); + // Two who agree on neither cannot: the salt is drawn fresh per build. This + // is the assertion that makes the pinning above non-vacuous. + expect(result.defaultedA).not.toBe(result.defaultedB); + expect(result.defaultedA).not.toBe(result.pinnedA); + // And the bound block is really pinned, not merely passed: the equality + // above would hold even if the override were ignored, because this closure + // produces no blocks, so the default bound block is the same for both + // builds. Varying it alone is what makes that impossible. + expect(result.otherBlock).not.toBe(result.pinnedA); + }); + + test("the approval expiration is off by default and rejects zero", async ({ + run, + }) => { + const result = await run(async ({ client, sdk, helpers }) => { + const { multisigAccountId } = + await helpers.setupMultisigWithConsumableNote(); + // Fresh per call, for the reason given in the previous test: reusing one + // made `withExpiry !== withoutExpiry` pass because the salts differed, + // not because the delta reached the preimage. + const salt = () => new sdk.Word(sdk.u64Array([5, 6, 7, 8])); + const authArgFor = async (delta) => + ( + await client.feeAwareTransactionRequestBuilder( + multisigAccountId, + delta, + salt(), + 1 + ) + ) + .build() + .authArg() + ?.toHex(); + + let zeroError = null; + try { + await authArgFor(0); + } catch (err) { + zeroError = String(err?.message ?? err); + } + + return { + withoutExpiry: await authArgFor(undefined), + withExpiry: await authArgFor(100), + zeroError, + }; + }); + + // A delta is part of the signed preimage, so setting one must change the + // commitment. Equal values would mean the option never reached the kernel. + expect(result.withoutExpiry).toBeTruthy(); + expect(result.withExpiry).toBeTruthy(); + expect(result.withExpiry).not.toBe(result.withoutExpiry); + // Zero is refused where the caller can see why, not silently read as "never". + expect(result.zeroError).toContain("approvalExpirationDelta"); + // The message is assembled from string literals concatenated across source + // lines. A stray run of spaces at a join would survive silently into what + // the caller sees, so assert there is none. + expect(result.zeroError).not.toMatch(/ {2,}/); + }); +}); + test.describe("fee conversion salt", () => { test("block headers expose the chain's fee parameters", async ({ run }) => { const result = await run(async ({ client, sdk, helpers }) => { @@ -41,7 +155,9 @@ test.describe("fee conversion salt", () => { const anchor = await client.chainAnchorForRequest(request); const header = anchor.blockHeader(); - const feeFaucetId = header.feeFaucetId(); + // Since 0.17 the fee asset lives in the protocol configuration, not in the + // header, so the client reports it rather than the block. + const feeFaucetId = await client.feeFaucetId(); return { baseFee: header.verificationBaseFee(), @@ -51,11 +167,10 @@ test.describe("fee conversion salt", () => { feeFaucetRoundTrips: sdk.AccountId.fromHex(feeFaucetId.toString()).toString() === feeFaucetId.toString(), - // Reading the header twice must agree — a fee faucet that changes - // between reads would mean we are not reading the header's own field. - feeFaucetIsStable: - anchor.blockHeader().feeFaucetId().toString() === - feeFaucetId.toString(), + // Pinning the value to the faucet the client was configured with needs + // an input the test chose, which the mock chain does not give it; that + // assertion lives in miden_client_api.test.ts against a client created + // with a known faucet. }; }); @@ -65,7 +180,6 @@ test.describe("fee conversion salt", () => { expect(result.baseFee).toBeGreaterThanOrEqual(0); expect(result.baseFee).toBeLessThanOrEqual(0xffffffff); expect(result.feeFaucetRoundTrips).toBe(true); - expect(result.feeFaucetIsStable).toBe(true); }); test("withFeeConversionSalt declares the salt and survives serialization", async ({ @@ -226,11 +340,12 @@ test.describe("fee conversion salt", () => { test("convenience constructors leave the fee to miden-client on this account", async ({ run, }) => { - // The constructors declare a salt only where the executing account must - // choose its own — the multisig flavours — and only on a chain that charges. - // `setupWalletAndFaucet` yields a single-sig wallet on the zero-fee mock - // chain, so both gates are shut and the request must come back byte-identical - // to one from a bare builder: no salt, and no auth arg. miden-client commits + // The constructors declare nothing unless the executing account must choose + // its own salt — the multisig flavours. `setupWalletAndFaucet` yields a + // single-sig wallet, so the request must come back byte-identical to one + // from a bare builder: no salt, and no auth arg. The mock chain's zero base + // fee is not what makes that true; since 0.17 a multisig would carry auth + // args here too. miden-client commits // the native conversion info itself under its fixed default salt, which is // exactly what a single-sig account wants and what keeps its signed summary // reproducible. diff --git a/crates/web-client/test/foreign_account_inputs.test.ts b/crates/web-client/test/foreign_account_inputs.test.ts deleted file mode 100644 index c4a10a92..00000000 --- a/crates/web-client/test/foreign_account_inputs.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -// @ts-nocheck -import { test, expect } from "./test-setup"; - -test("foreign account inputs preserve descending account order, including prefetched entries", async ({ - run, -}) => { - const result = await run(async ({ client, sdk, helpers }) => { - const { wallet, faucet } = await helpers.setupWalletAndFaucet(); - await helpers.mockMintAndConsume(wallet.id(), faucet.id()); - const accounts = [ - await client.getAccount(wallet.id()), - await client.getAccount(faucet.id()), - ].sort((a, b) => b.id().toString().localeCompare(a.id().toString())); - const foreignAccounts = accounts.map((account) => - sdk.ForeignAccount.private(account) - ); - const fetchInputs = async (entries) => { - const array = new sdk.ForeignAccountArray(); - for (const entry of entries) array.push(entry); - const inputs = await client.getForeignAccountInputs( - array, - await client.getSyncHeight() - ); - return Array.isArray(inputs) - ? inputs - : Array.from({ length: inputs.length() }, (_, i) => inputs.get(i)); - }; - const inputs = await fetchInputs(foreignAccounts); - const prefetched = inputs.map((input) => - sdk.ForeignAccount.prefetched( - sdk.AccountInputs.deserialize(input.serialize()) - ) - ); - // Repeat an entry to catch deduplication as well as sorting by account ID. - const repeated = await fetchInputs([ - prefetched[0], - foreignAccounts[1], - prefetched[0], - ]); - return { - expected: accounts.map((account) => account.id().toString()), - callerIds: foreignAccounts.map((account) => - (account.account_id - ? account.account_id() - : account.accountId() - ).toString() - ), - fetched: inputs.map((input) => input.accountId().toString()), - repeated: repeated.map((input) => input.accountId().toString()), - }; - }); - expect(result.fetched).toEqual(result.expected); - expect(result.callerIds).toEqual(result.expected); - expect(result.repeated).toEqual([ - result.expected[0], - result.expected[1], - result.expected[0], - ]); -}); diff --git a/crates/web-client/test/fpi.test.ts b/crates/web-client/test/fpi.test.ts index 4de079d4..87f22c9a 100644 --- a/crates/web-client/test/fpi.test.ts +++ b/crates/web-client/test/fpi.test.ts @@ -162,29 +162,14 @@ test.describe("fpi test", () => { const hasStorageSlotRequirements = !!foreignAccount.storageSlotRequirements(); - // Fetch the foreign account's state and witness up front and declare them - // back as prefetched, so nothing is fetched for the account while the - // transaction executes. - const prefetchBlock = await intClient.getSyncHeight(); - const fetchedInputs = await intClient.getForeignAccountInputs( - new sdk.ForeignAccountArray([foreignAccount]), - prefetchBlock - ); - // Node returns a plain array; the browser returns a typed WASM array. - const inputs = Array.isArray(fetchedInputs) - ? fetchedInputs - : Array.from({ length: fetchedInputs.length() }, (_, i) => - fetchedInputs.get(i) - ); - - const restoredInputs = sdk.AccountInputs.deserialize( - inputs[0].serialize() - ); - const prefetchedAccount = sdk.ForeignAccount.prefetched(restoredInputs); + // The account's state and witness are fetched against the transaction's + // own reference block, and the vault and storage-map entries the foreign + // code reads are resolved during execution. + const referenceBlock = await intClient.getSyncHeight(); let txRequest2 = new sdk.TransactionRequestBuilder() .withCustomScript(compiledTxScript) - .withForeignAccounts(new sdk.ForeignAccountArray([prefetchedAccount])) + .withForeignAccounts(new sdk.ForeignAccountArray([foreignAccount])) .build(); let txResult2 = await intClient.executeTransaction( @@ -211,9 +196,7 @@ test.describe("fpi test", () => { foreignAccountIdStr, foreignAccountIdFromAccessor, hasStorageSlotRequirements, - prefetchedCount: inputs.length, - prefetchedAccountIdStr: restoredInputs.accountId().toString(), - prefetchBlock, + referenceBlock, proofAccountId: accountProof.accountId().toString(), proofBlockNum: accountProof.blockNum(), proofCommitmentHex: accountProof.accountCommitment().toHex(), @@ -228,9 +211,7 @@ test.describe("fpi test", () => { test.skip(true, "requires running node"); return; } - expect(result.prefetchedCount).toEqual(1); - expect(result.prefetchedAccountIdStr).toEqual(result.foreignAccountIdStr); - expect(result.prefetchBlock).toBeGreaterThan(0); + expect(result.referenceBlock).toBeGreaterThan(0); expect(result.proofAccountId).toEqual(result.foreignAccountIdStr); // Asserts on the value, not just that the call returned: an accessor // rewired to the wrong field would still be defined. diff --git a/crates/web-client/test/global.test.d.ts b/crates/web-client/test/global.test.d.ts index daffc18e..816b060c 100644 --- a/crates/web-client/test/global.test.d.ts +++ b/crates/web-client/test/global.test.d.ts @@ -91,6 +91,8 @@ declare global { MockWasmWebClient: typeof MockWasmWebClient; remoteProverUrl?: string; remoteProverInstance: TransactionProver; + /** The test node's own prover, exposed whether or not the run opted into one. */ + localTxProverUrl: string; Account: typeof Account; AccountFile: typeof AccountFile; AccountBuilder: typeof AccountBuilder; @@ -115,7 +117,6 @@ declare global { FeltArray: typeof FeltArray; ForeignAccount: typeof ForeignAccount; FungibleAsset: typeof FungibleAsset; - FungibleAssetDelta: typeof FungibleAssetDelta; InputNoteRecord: typeof InputNoteRecord; Library: typeof Library; NetworkId: typeof NetworkId; @@ -178,6 +179,9 @@ declare global { createClient: () => Promise; rpcUrl: string; + // The chain's fee faucet, so `refreshClient` can rebuild a client that can + // still execute. Undefined when the SDK already knows one for the network. + feeFaucetId: string | undefined; // Add the helpers namespace helpers: { @@ -188,6 +192,15 @@ declare global { ) => Promise; waitForBlocks: (amountOfBlocks: number) => Promise; refreshClient: (initSeed?: Uint8Array) => Promise; + // Build another client against the node under test. Keeps the wrapper's + // positional list, and the fee faucet it ends with, in one place. + createClient: (storeName?: string, seed?: Uint8Array) => Promise; + createClientWithKeystore: ( + getKeyCb?: unknown, + insertKeyCb?: unknown, + signCb?: unknown, + storeName?: string + ) => Promise; parseNetworkId: (networkId: string) => NetworkId; generateKeyWithScheme: (signatureScheme: string) => AuthSecretKey; }; diff --git a/crates/web-client/test/miden_client_api.test.ts b/crates/web-client/test/miden_client_api.test.ts index dd944ca5..f1d06fbc 100644 --- a/crates/web-client/test/miden_client_api.test.ts +++ b/crates/web-client/test/miden_client_api.test.ts @@ -1169,6 +1169,7 @@ nodeTest.describe("MidenClient API - Integration", () => { const result = await page.evaluate(async () => { const client = await window.MidenClient.create({ rpcUrl: window.rpcUrl, + feeFaucetId: window.feeFaucetId, storeName: "miden_client_api_create_test", }); @@ -1185,12 +1186,71 @@ nodeTest.describe("MidenClient API - Integration", () => { expect(result.syncHeight).toBeGreaterThanOrEqual(0); }); + // Pins the accessor to its input. Reading it twice and comparing cannot fail; + // comparing it to the faucet the client was created with fails the moment it + // reports any other valid id. + nodeTest( + "feeFaucetId reports the faucet the client was created with", + async ({ page }) => { + const result = await page.evaluate(async () => { + const client = await window.MidenClient.create({ + rpcUrl: window.rpcUrl, + feeFaucetId: window.feeFaucetId, + storeName: "miden_client_api_fee_faucet_test", + }); + + // The configured id is whatever the harness was given - bech32 from a + // node's bootstrap log, hex from an override - so normalize both sides + // through the SDK's own parsers before comparing. + const canonical = (id) => { + try { + return window.AccountId.fromBech32(id).toString(); + } catch { + return window.AccountId.fromHex(id).toString(); + } + }; + + return { + configured: canonical(window.feeFaucetId), + reported: (await client.feeFaucetId()).toString(), + }; + }); + + expect(result.configured).toBe(result.reported); + } + ); + + // The tripwire for every quickstart that now has to pass `feeFaucetId`: while + // KNOWN_FEE_FAUCETS is empty a client without one must fail, and fail naming + // the option. When a 0.17 network publishes its genesis and the table gains an + // entry, this test fails - and the docs that call the option mandatory are + // what has to change with it. + nodeTest( + "creating a client without a fee faucet fails and names the option", + async ({ page }) => { + const message = await page.evaluate(async () => { + try { + await window.MidenClient.create({ + rpcUrl: window.rpcUrl, + storeName: "miden_client_api_no_fee_faucet_test", + }); + return null; + } catch (err) { + return String(err?.message ?? err); + } + }); + + expect(message).toContain("feeFaucetId"); + } + ); + nodeTest( "accounts.create wallet and faucet via integration", async ({ page }) => { const result = await page.evaluate(async () => { const client = await window.MidenClient.create({ rpcUrl: window.rpcUrl, + feeFaucetId: window.feeFaucetId, storeName: "miden_client_api_accounts_test", }); await client.sync(); @@ -1225,6 +1285,7 @@ nodeTest.describe("MidenClient API - Integration", () => { const result = await page.evaluate(async () => { const client = await window.MidenClient.create({ rpcUrl: window.rpcUrl, + feeFaucetId: window.feeFaucetId, storeName: "miden_client_api_send_test", }); await client.sync(); @@ -1289,6 +1350,7 @@ nodeTest.describe("MidenClient API - Integration", () => { const result = await page.evaluate(async () => { const client = await window.MidenClient.create({ rpcUrl: window.rpcUrl, + feeFaucetId: window.feeFaucetId, storeName: "miden_client_api_txlist_test", }); await client.sync(); @@ -1337,6 +1399,7 @@ nodeTest.describe("MidenClient API - Integration", () => { const result = await page.evaluate(async () => { const client = await window.MidenClient.create({ rpcUrl: window.rpcUrl, + feeFaucetId: window.feeFaucetId, storeName: "miden_client_api_notes_test", }); await client.sync(); diff --git a/crates/web-client/test/network_note.test.ts b/crates/web-client/test/network_note.test.ts index ce7c0c5f..9dae29af 100644 --- a/crates/web-client/test/network_note.test.ts +++ b/crates/web-client/test/network_note.test.ts @@ -15,7 +15,7 @@ test.describe("network note tests", () => { test("custom-script note carries a NetworkAccountTarget and it survives submit", async ({ run, }) => { - const result = await run(async ({ client, sdk }) => { + const result = await run(async ({ client, sdk, helpers }) => { await client.syncState(); // The note script is reused for the allowlist root and the note itself. @@ -25,7 +25,9 @@ test.describe("network note tests", () => { // calling `estimate_note_fee` on the target, so the target must be a real // network account — a plain wallet does not expose that procedure — and it // must be committed on-chain at the transaction's reference block. - const feeFaucet = await client.newFaucet( + // This faucet only mints the note that deploys the account; the account's + // own fee faucet is the chain's, below. + const faucet = await client.newFaucet( sdk.AccountStorageMode.public(), false, "FEE", @@ -40,9 +42,11 @@ test.describe("network note tests", () => { new sdk.NoteScriptFee(p2idScript.root(), sdk.u64(0)), ]; // Yields the auth component plus the components backing its fee policy. + // The fee faucet must be the chain's: a 0.17 node never serves a network + // account whose fee asset differs from its protocol configuration's. const networkAuth = sdk.AccountComponent.createNetworkAuthComponents( allowedNotes, - feeFaucet.id() + await client.feeFaucetId() ); const seed = new Uint8Array(32); @@ -56,12 +60,15 @@ test.describe("network note tests", () => { const networkAccount = networkAccountBuilder.build().account; await client.newAccount(networkAccount, false); - // Scriptless deploy: the network auth component bumps the nonce itself, so - // an empty transaction commits the account on-chain. - await client.submitNewTransaction( - networkAccount.id(), - new sdk.TransactionRequestBuilder().build() - ); + // Deploy by consuming a note, not by an empty transaction: since 0.17 the + // network auth component asserts the transaction had an effect before fee + // payment (`ERR_NETWORK_ACCOUNT_TRANSACTION_HAS_NO_EFFECT` - an input note, + // an output note, or a changed account state), and a scriptless deploy has + // none. A minted P2ID note is the cheapest effect the account's own + // allowlist already permits: `allowedNotes` above carries that script root. + await helpers.mockMintAndConsume(networkAccount.id(), faucet.id(), { + publicNote: true, + }); await client.proveBlock(); await client.syncState(); @@ -103,11 +110,25 @@ test.describe("network note tests", () => { const builtIsNetworkNote = note.isNetworkNote(); const builtAttachmentCount = note.attachments().length; - // Submit as an own output note. + // Submit as an own output note, declaring the target as a foreign + // account. Since 0.17 the kernel prices a NetworkAccountTarget note by + // calling `estimate_note_fee` on the target; this client happens to hold + // that account locally, so it would resolve either way, but a consumer + // whose client does not must declare it, and this gate should exercise + // the shape they need. `client.transactions.createNetworkNote` does it + // for you. const ownOutputs = new sdk.NoteArray(); ownOutputs.push(note); + const targetAccounts = new sdk.ForeignAccountArray(); + targetAccounts.push( + sdk.ForeignAccount.public( + networkAccount.id(), + new sdk.AccountStorageRequirements() + ) + ); const request = new sdk.TransactionRequestBuilder() .withOwnOutputNotes(ownOutputs) + .withForeignAccounts(targetAccounts) .build(); const txId = await client.submitNewTransaction(sender.id(), request); await client.proveBlock(); diff --git a/crates/web-client/test/network_transaction.test.ts b/crates/web-client/test/network_transaction.test.ts index a100d74c..cab19be5 100644 --- a/crates/web-client/test/network_transaction.test.ts +++ b/crates/web-client/test/network_transaction.test.ts @@ -89,26 +89,46 @@ const networkCounterTransaction = async ( builder.linkDynamicAccountComponentCode(counterComponent.componentCode()); const noteScript = await builder.compileNoteScript(noteScriptCode); + // The deploy needs an effect: since 0.17 the network-account auth component + // asserts the transaction consumed a note, created one, or changed account + // state BEFORE it pays the fee, so an empty transaction aborts with + // `network account transactions must have an effect before fee payment`. + // Bumping its own counter is the effect used here, and the script is + // allowlisted below so the auth component admits it. (Consuming a note would + // also work - `createNetworkAuthComponents` installs BasicWallet itself and + // 0.17 allowlists the P2ID root by default - but that costs a mint and a + // second transaction, and this route also exercises the tx-script + // allowlist.) + const deployScript = builder.compileTxScript(` + use external_contract::counter_contract + @transaction_script + pub proc main + call.counter_contract::increment_count + end + `); + // A network account is a Public account carrying the network-account auth // component. Its note-script allowlist is the standardized storage slot the // node inspects to identify the account as a network account and route // matching notes to it. // // Each allowed note script carries the fee the account charges to consume it, - // denominated in the asset of a fee faucet. This test node charges no + // denominated in the chain's fee asset. This test node charges no // verification fee, so the note is priced at zero. - const feeFaucet = await client.newFaucet( - window.AccountStorageMode.tryFromStr("public"), - false, - "FEE", - "FEE", - 8, - BigInt(10000000), - 2 - ); + // + // The fee faucet has to be the CHAIN'S, not one minted here. Since 0.17 the + // node's network-transaction builder checks a network account's fee asset + // against the protocol configuration and refuses to execute for it + // otherwise - "network account fee asset does not match the protocol + // configuration". Nothing on the client side reports that: the note is + // simply never consumed. + const feeFaucetId = await client.feeFaucetId(); const networkAuth = window.AccountComponent.createNetworkAuthComponents( [new window.NoteScriptFee(noteScript.root(), BigInt(0))], - feeFaucet.id() + feeFaucetId, + // Any transaction script but the canonical expiration one is refused + // unless it is named here. + [deployScript.root()] ); const seed = new Uint8Array(32); @@ -132,12 +152,14 @@ const networkCounterTransaction = async ( ?.map((root) => root.toHex()); const allowlistedNoteRoot = noteScript.root().toHex(); - // Scriptless deploy: the network-account auth component forbids tx scripts - // and bumps the nonce on its own, so an empty transaction is enough to commit - // the account on-chain. The counter is 0 after deployment. + // Deploy with the allowlisted counter bump, which both commits the account + // on-chain and gives the transaction the effect 0.17 requires. The counter + // therefore reads 1 after deployment, not 0. const deployTx = await window.helpers.executeAndApplyTransaction( built.account.id(), - new window.TransactionRequestBuilder().build() + new window.TransactionRequestBuilder() + .withCustomScript(deployScript) + .build() ); await window.helpers.waitForTransaction( deployTx.executedTransaction().id().toHex() @@ -178,27 +200,83 @@ const networkCounterTransaction = async ( [target.toAttachment()] ); - const ownOutputs = new window.NoteArray(); - ownOutputs.push(note); - const emitTx = await window.helpers.executeAndApplyTransaction( - sender.id(), - new window.TransactionRequestBuilder() - .withOwnOutputNotes(ownOutputs) - .build() - ); + // Since 0.17 the kernel prices a NetworkAccountTarget note through a + // procedure call on the target account, so the emitting transaction declares + // it as a foreign account. This request is built by hand rather than through + // `transactions.createNetworkNote`, which declares it for you. + // + // Pricing the note calls `estimate_note_fee` on the target, which applies + // the standards' default expiration delta: this transaction must be + // included within 20 blocks of its reference block, and an expiration can + // only be lowered, so nothing here can widen it. A local WASM prove on a + // CI runner can exceed that, and the node then rejects the submission as + // expired. Re-execute against a fresh reference block, which is what a + // consumer has to do; it is not a blanket retry, and any other failure + // still fails the test on the first attempt. + // Fresh arrays per attempt: both builder methods take their array BY VALUE, + // so wasm-bindgen moves the handle and a second build would hit a consumed + // one. `push` borrows and clones, so the note and the foreign account can + // be reused. Without this the retry below could never retry - it would + // throw `null pointer passed to rust` instead. + const emitRequest = () => { + const notes = new window.NoteArray(); + notes.push(note); + const accounts = new window.ForeignAccountArray(); + accounts.push( + window.ForeignAccount.public( + built.account.id(), + new window.AccountStorageRequirements() + ) + ); + return new window.TransactionRequestBuilder() + .withOwnOutputNotes(notes) + .withForeignAccounts(accounts) + .build(); + }; + + let emitTx; + for (let attempt = 0; ; attempt++) { + try { + emitTx = await window.helpers.executeAndApplyTransaction( + sender.id(), + emitRequest(), + // Prove this one remotely. A local WASM prove on a CI runner takes + // longer than the 20-block window the pricing call imposes, so it + // expires however many times it is retried; the node runs a prover + // beside its RPC. Done per call rather than by configuring the run, + // because TEST_MIDEN_PROVER_URL also flips Playwright's + // `fullyParallel` for every project. + window.TransactionProver.newRemoteProver( + window.localTxProverUrl, + BigInt(120_000) + ) + ); + break; + } catch (err) { + const message = String(err?.message ?? err); + if (attempt >= 2 || !message.includes("transaction expired")) throw err; + await client.syncState(); + } + } await window.helpers.waitForTransaction( emitTx.executedTransaction().id().toHex() ); // The node's network-transaction builder consumes the note in a subsequent - // block and bumps the counter to 1. Poll until it does or the window elapses. + // block and bumps the counter again. Poll until it does or the window + // elapses. Record the blocks spanned: when this times out the assertion can + // only say "1 !== 2", which does not distinguish a builder that never acted + // from a window that was too short, and the page's console is not forwarded + // to the run log. + const pollStartBlock = await client.getSyncHeight(); let finalCounter = deployedCounter; - for (let i = 0; i < 15; i++) { + for (let i = 0; i < 40; i++) { await window.helpers.waitForBlocks(1); await client.syncState(); finalCounter = await readCounter(); - if (finalCounter === "1") break; + if (finalCounter === "2") break; } + const pollEndBlock = await client.getSyncHeight(); // The deployed network account's code carries the counter component. const finalAccount = await client.getAccount(built.account.id()); @@ -212,6 +290,8 @@ const networkCounterTransaction = async ( return { deployedCounter, finalCounter, + pollStartBlock, + pollEndBlock, hasCounterComponent, isNetworkAccount, allowlist, @@ -232,6 +312,8 @@ test.describe("network transaction tests", () => { const { deployedCounter, finalCounter, + pollStartBlock, + pollEndBlock, hasCounterComponent, isNetworkAccount, allowlist, @@ -240,23 +322,28 @@ test.describe("network transaction tests", () => { senderAllowlist, } = await networkCounterTransaction(page); // Readback: the built account identifies as a network account and its - // allowlist holds the note-script root it was created with, plus the two - // roots the protocol allowlists itself (the network-account config note and - // the fee-sponsorship note). The allowlist is a set ordered by root value - // rather than insertion, so assert membership and size instead of contents. - // The size is load-bearing: a longer allowlist means the account would - // auto-consume note scripts it was never meant to. + // allowlist holds the note-script root it was created with, plus the three + // roots the protocol allowlists itself - the network-account config note, + // the fee-sponsorship note, and, since 0.17, P2ID. The allowlist is a set + // ordered by root value rather than insertion, so assert membership and size + // instead of contents. The size is load-bearing: a longer allowlist means + // the account would auto-consume note scripts it was never meant to, which + // is exactly what the P2ID default does - upstream marks it a stopgap until + // a dedicated DEPLOY note script lands. expect(isNetworkAccount).toBe(true); expect(allowlist).toContain(allowlistedNoteRoot); - expect(allowlist).toHaveLength(3); + expect(allowlist).toHaveLength(4); // A plain wallet is not a network account. expect(senderIsNetworkAccount).toBe(false); expect(senderAllowlist).toBeUndefined(); - // The scriptless deploy leaves the counter at 0 (empty once normalized) — - // a network account cannot run a deploy script. - expect(deployedCounter).toBeFalsy(); - // The node's network transaction consumed the note and bumped it to 1. - expect(finalCounter).toEqual("1"); + // The deploy ran the allowlisted counter bump, so it reads 1. + expect(deployedCounter).toEqual("1"); + // The node's network transaction consumed the note and bumped it again. The + // block span is in the message so a timeout says how long the builder had. + expect( + finalCounter, + `counter after polling blocks ${pollStartBlock}..${pollEndBlock}` + ).toEqual("2"); // The network account's on-chain code carries the counter component. expect(hasCounterComponent).toBe(true); }); diff --git a/crates/web-client/test/node-adapter.ts b/crates/web-client/test/node-adapter.ts index f81fbab0..6423e48b 100644 --- a/crates/web-client/test/node-adapter.ts +++ b/crates/web-client/test/node-adapter.ts @@ -361,7 +361,8 @@ export const WasmWebClient = { rpcUrl?: string, noteTransportUrl?: any, seed?: any, - storeName?: string + storeName?: string, + feeFaucetId?: string ) => { const dir = tmpTestDir(); const client = new sdk.WebClient(); @@ -375,7 +376,7 @@ export const WasmWebClient = { normSeed ?? null, path.join(dir, `${storeName || "store"}.db`), path.join(dir, "keystore"), - false + feeFaucetId ?? null ); return wrapClient(client, storeName); }, diff --git a/crates/web-client/test/playwright.global.setup.ts b/crates/web-client/test/playwright.global.setup.ts index 93bb8b55..3ebb9203 100644 --- a/crates/web-client/test/playwright.global.setup.ts +++ b/crates/web-client/test/playwright.global.setup.ts @@ -26,6 +26,16 @@ export function isLocalhost(): boolean { return !network || network === "localhost"; } +// Fee faucet of the chain under test. Since 0.17 the fee asset lives in the +// protocol configuration rather than the block header, so a client cannot execute +// until it is told which faucet issues it, and a local node mints a fresh one at +// each genesis. `scripts/start-test-node.sh` reports it as +// "Native faucet account id:" in its bootstrap log; CI reads that line into this +// variable. Left unset, the SDK falls back to what it knows for the network. +export function getFeeFaucetId(): string | undefined { + return process.env.TEST_MIDEN_FEE_FAUCET_ID || undefined; +} + // Determine RPC URL from environment or default to localhost export function getRpcUrl(): string { if (process.env.TEST_MIDEN_RPC_URL) { @@ -102,7 +112,8 @@ export const test = base.extend<{ forEachTest: void }>({ rpcUrl, undefined, undefined, - storeName + storeName, + getFeeFaucetId() ); setClient(client); } else { @@ -124,7 +135,13 @@ export const test = base.extend<{ forEachTest: void }>({ await page.goto("http://localhost:8080"); await page.evaluate( - async ({ rpcUrl, proverUrl, storeName }) => { + async ({ + rpcUrl, + proverUrl, + localTxProverUrl, + storeName, + feeFaucetId, + }) => { // Import the sdk classes and attach them // to the window object for testing const sdkExports = await import("./index.js"); @@ -139,9 +156,14 @@ export const test = base.extend<{ forEachTest: void }>({ rpcUrl, undefined, undefined, - storeName + storeName, + undefined, // logLevel + undefined, // useWorker, defaulted + undefined, // observability + feeFaucetId ); window.rpcUrl = rpcUrl; + window.feeFaucetId = feeFaucetId; window.storeName = storeName; window.client = client; @@ -149,6 +171,49 @@ export const test = base.extend<{ forEachTest: void }>({ // Create a namespace for helper functions window.helpers = window.helpers || {}; + // One place that knows the wrapper's positional list, so a test that + // wants a second client against the same node cannot silently drop + // the fee faucet - without it the client cannot execute or screen + // notes, and the failure surfaces far from the call. + window.helpers.createClient = (clientStoreName, seed) => + window.WasmWebClient.createClient( + window.rpcUrl, + undefined, + seed, + clientStoreName, + undefined, // logLevel + undefined, // useWorker, defaulted + undefined, // observability + window.feeFaucetId + ); + + window.helpers.createClientWithKeystore = ( + getKeyCb, + insertKeyCb, + signCb, + clientStoreName + ) => + window.WasmWebClient.createClientWithExternalKeystore( + window.rpcUrl, + undefined, + undefined, + clientStoreName, + getKeyCb, + insertKeyCb, + signCb, + undefined, // logLevel + undefined, // useWorker, defaulted + undefined, // observability + window.feeFaucetId + ); + + // The test node runs a prover beside its RPC. Expose its URL + // unconditionally, separately from `remoteProverUrl`: a test that + // needs to prove remotely can then do so without setting + // TEST_MIDEN_PROVER_URL, which would flip `fullyParallel` for every + // project in the run (playwright.config.ts). + window.localTxProverUrl = localTxProverUrl; + // Add the remote prover url to window window.remoteProverUrl = proverUrl; if (window.remoteProverUrl) { @@ -196,14 +261,20 @@ export const test = base.extend<{ forEachTest: void }>({ transactionRequest ); - const useRemoteProver = - prover != null && window.remoteProverUrl != null; - const proverToUse = useRemoteProver - ? window.TransactionProver.newRemoteProver( - window.remoteProverUrl, - BigInt(120_000) - ) - : window.TransactionProver.newLocalProver(); + // A caller that hands over a prover gets that prover. The older + // shape rebuilt one from `window.remoteProverUrl` and ignored the + // argument, so a test could not prove remotely unless the whole + // run was configured for it - and configuring the run flips + // Playwright's `fullyParallel` for every project, which is far + // more than one test should cost. + const proverToUse = + prover ?? + (window.remoteProverUrl != null + ? window.TransactionProver.newRemoteProver( + window.remoteProverUrl, + BigInt(120_000) + ) + : window.TransactionProver.newLocalProver()); const proven = await client.proveTransaction(result, proverToUse); const submissionHeight = await client.submitProvenTransaction( @@ -237,7 +308,11 @@ export const test = base.extend<{ forEachTest: void }>({ rpcUrl, undefined, initSeed, - window.storeName + window.storeName, + undefined, // logLevel + undefined, // useWorker, defaulted + undefined, // observability + window.feeFaucetId ); window.client = client; await window.client.syncState(); @@ -265,7 +340,9 @@ export const test = base.extend<{ forEachTest: void }>({ { rpcUrl: getRpcUrl(), proverUrl: getProverUrl() ?? null, + localTxProverUrl: `http://localhost:${REMOTE_TX_PROVER_PORT}`, storeName, + feeFaucetId: getFeeFaucetId(), } ); } diff --git a/crates/web-client/test/remote_keystore.test.ts b/crates/web-client/test/remote_keystore.test.ts index 4a6791a7..e6dfe755 100644 --- a/crates/web-client/test/remote_keystore.test.ts +++ b/crates/web-client/test/remote_keystore.test.ts @@ -15,16 +15,11 @@ test.describe("remote keystore", () => { const signCb = async (_publicKeyCommitment: string, _message: string) => { return undefined; }; - const client = - await window.WasmWebClient.createClientWithExternalKeystore( - window.rpcUrl!, - undefined, - undefined, - undefined, - getKeyCb, - insertKeyCb, - signCb - ); + const client = await window.helpers.createClientWithKeystore( + getKeyCb, + insertKeyCb, + signCb + ); return client; }); expect(client).toBeDefined(); @@ -43,16 +38,11 @@ test.describe("remote keystore", () => { publicKeyCommitment = publicKeyCommitmentStr; secretKey = secretKeyStr; }; - const client = - await window.WasmWebClient.createClientWithExternalKeystore( - window.rpcUrl!, - undefined, - undefined, - undefined, - undefined, - insertKeyCb, - undefined - ); + const client = await window.helpers.createClientWithKeystore( + undefined, + insertKeyCb, + undefined + ); await client.newWallet( window.AccountStorageMode.private(), window.AuthScheme.AuthRpoFalcon512, @@ -89,16 +79,11 @@ test.describe("remote keystore", () => { return undefined; }; - const client = - await window.WasmWebClient.createClientWithExternalKeystore( - window.rpcUrl!, - undefined, - undefined, - undefined, - getKeyCb, - insertKeyCb, - undefined - ); + const client = await window.helpers.createClientWithKeystore( + getKeyCb, + insertKeyCb, + undefined + ); const wallet = await client.newWallet( window.AccountStorageMode.private(), @@ -154,16 +139,11 @@ test.describe("remote keystore", () => { return serializedSig; }; - const client = - await window.WasmWebClient.createClientWithExternalKeystore( - window.rpcUrl!, - undefined, - undefined, - undefined, - undefined, - insertKeyCb, - signCb - ); + const client = await window.helpers.createClientWithKeystore( + undefined, + insertKeyCb, + signCb + ); // Create faucet first so insertKeyCb captures its public key const faucet = await client.newFaucet( diff --git a/crates/web-client/test/store_isolation.test.ts b/crates/web-client/test/store_isolation.test.ts index 07848ac0..d715c2cb 100644 --- a/crates/web-client/test/store_isolation.test.ts +++ b/crates/web-client/test/store_isolation.test.ts @@ -6,12 +6,7 @@ test.describe("Store Isolation Tests", () => { page, }) => { const result = await page.evaluate(async () => { - const client = await window.WasmWebClient.createClient( - window.rpcUrl, - undefined, - undefined, - undefined - ); + const client = await window.helpers.createClient(undefined); await client.syncState(); const databases = await window.indexedDB.databases(); @@ -37,12 +32,7 @@ test.describe("Store Isolation Tests", () => { window.AuthScheme.AuthRpoFalcon512 ); - const client2 = await window.WasmWebClient.createClient( - window.rpcUrl, - undefined, - undefined, - "IsolatedStore1" - ); + const client2 = await window.helpers.createClient("IsolatedStore1"); await client2.syncState(); const databases = await window.indexedDB.databases(); @@ -68,12 +58,7 @@ test.describe("Store Isolation Tests", () => { test("reconnecting to same store preserves data", async ({ page }) => { const result = await page.evaluate(async () => { - const client1 = await window.WasmWebClient.createClient( - window.rpcUrl, - undefined, - undefined, - "PersistentStore" - ); + const client1 = await window.helpers.createClient("PersistentStore"); await client1.syncState(); const wallet = await client1.newWallet( @@ -82,12 +67,7 @@ test.describe("Store Isolation Tests", () => { ); const walletId = wallet.id().toString(); - const client1b = await window.WasmWebClient.createClient( - window.rpcUrl, - undefined, - undefined, - "PersistentStore" - ); + const client1b = await window.helpers.createClient("PersistentStore"); const accounts = await client1b.getAccounts(); const accountIds = accounts.map((a: any) => a.id().toString()); @@ -106,12 +86,7 @@ test.describe("Store Isolation Tests", () => { test("custom store name creates isolated database", async ({ page }) => { const result = await page.evaluate(async () => { const customStoreName = "MyCustomStore_v1"; - const client = await window.WasmWebClient.createClient( - window.rpcUrl, - undefined, - undefined, - customStoreName - ); + const client = await window.helpers.createClient(customStoreName); await client.syncState(); await client.newWallet( @@ -143,18 +118,8 @@ test.describe("Store Isolation Tests", () => { page, }) => { const result = await page.evaluate(async () => { - const client1 = await window.WasmWebClient.createClient( - window.rpcUrl, - undefined, - undefined, - "ConcurrentStore1" - ); - const client2 = await window.WasmWebClient.createClient( - window.rpcUrl, - undefined, - undefined, - "ConcurrentStore2" - ); + const client1 = await window.helpers.createClient("ConcurrentStore1"); + const client2 = await window.helpers.createClient("ConcurrentStore2"); await Promise.all([client1.syncState(), client2.syncState()]); @@ -190,18 +155,8 @@ test.describe("Store Isolation Tests", () => { test("multiple accounts per store remain isolated", async ({ page }) => { const result = await page.evaluate(async () => { - const client1 = await window.WasmWebClient.createClient( - window.rpcUrl, - undefined, - undefined, - "MultiAccount1" - ); - const client2 = await window.WasmWebClient.createClient( - window.rpcUrl, - undefined, - undefined, - "MultiAccount2" - ); + const client1 = await window.helpers.createClient("MultiAccount1"); + const client2 = await window.helpers.createClient("MultiAccount2"); await Promise.all([client1.syncState(), client2.syncState()]); @@ -251,18 +206,8 @@ test.describe("Store Isolation Tests", () => { test("input notes are isolated between stores", async ({ page }) => { const result = await page.evaluate(async () => { - const client1 = await window.WasmWebClient.createClient( - window.rpcUrl, - undefined, - undefined, - "NoteStore1" - ); - const client2 = await window.WasmWebClient.createClient( - window.rpcUrl, - undefined, - undefined, - "NoteStore2" - ); + const client1 = await window.helpers.createClient("NoteStore1"); + const client2 = await window.helpers.createClient("NoteStore2"); await Promise.all([client1.syncState(), client2.syncState()]); diff --git a/crates/web-client/test/sync_lock.test.ts b/crates/web-client/test/sync_lock.test.ts index 3b3969e6..523d88ce 100644 --- a/crates/web-client/test/sync_lock.test.ts +++ b/crates/web-client/test/sync_lock.test.ts @@ -1,6 +1,10 @@ // @ts-nocheck import { expect } from "@playwright/test"; -import test, { getRpcUrl, RUN_ID } from "./playwright.global.setup"; +import test, { + getFeeFaucetId, + getRpcUrl, + RUN_ID, +} from "./playwright.global.setup"; import { BrowserContext, Page } from "@playwright/test"; test.describe("Sync Lock Tests", () => { @@ -146,12 +150,7 @@ test.describe("Sync Lock Tests", () => { const result = await page.evaluate(async () => { // Create two clients pointing to the same store const client1 = window.client; - const client2 = await window.WasmWebClient.createClient( - window.rpcUrl, - undefined, - undefined, - window.storeName // Same store name as client1 - ); + const client2 = await window.helpers.createClient(window.storeName); // Fire concurrent syncs from both clients const syncPromises = [client1.syncState(), client2.syncState()]; @@ -178,18 +177,8 @@ test.describe("Sync Lock Tests", () => { }) => { const result = await page.evaluate(async () => { const client1 = window.client; - const client2 = await window.WasmWebClient.createClient( - window.rpcUrl, - undefined, - undefined, - window.storeName - ); - const client3 = await window.WasmWebClient.createClient( - window.rpcUrl, - undefined, - undefined, - window.storeName - ); + const client2 = await window.helpers.createClient(window.storeName); + const client3 = await window.helpers.createClient(window.storeName); // Fire many concurrent syncs const syncPromises = [ @@ -220,18 +209,8 @@ test.describe("Sync Lock Tests", () => { }) => { const result = await page.evaluate(async () => { const client1 = window.client; // Uses window.storeName - const client2 = await window.WasmWebClient.createClient( - window.rpcUrl, - undefined, - undefined, - "SyncLockTestStore1" - ); - const client3 = await window.WasmWebClient.createClient( - window.rpcUrl, - undefined, - undefined, - "SyncLockTestStore2" - ); + const client2 = await window.helpers.createClient("SyncLockTestStore1"); + const client3 = await window.helpers.createClient("SyncLockTestStore2"); // Fire concurrent syncs to different stores const syncPromises = [ @@ -393,7 +372,7 @@ test.describe("Cross-Tab Sync Lock Tests", () => { const setupPage = async (page: Page) => { await page.goto("http://localhost:8080"); await page.evaluate( - async ({ rpcUrl, storeName }) => { + async ({ rpcUrl, storeName, feeFaucetId }) => { const sdkExports = await import("./index.js"); for (const [key, value] of Object.entries(sdkExports)) { window[key] = value; @@ -401,15 +380,26 @@ test.describe("Cross-Tab Sync Lock Tests", () => { window.rpcUrl = rpcUrl; // Both pages use the same store name for cross-tab coordination + // These pages set themselves up from a bare goto, so the global + // fixture never ran on them and window.helpers does not exist here; + // this is the one place the positional list is spelled out twice. const client = await window.WasmWebClient.createClient( rpcUrl, undefined, undefined, - storeName + storeName, + undefined, // logLevel + undefined, // useWorker, defaulted + undefined, // observability + feeFaucetId ); window.client = client; }, - { rpcUrl, storeName: crossTabStoreName } + { + rpcUrl, + storeName: crossTabStoreName, + feeFaucetId: getFeeFaucetId(), + } ); }; @@ -462,22 +452,29 @@ test.describe("Cross-Tab Sync Lock Tests", () => { const setupPage = async (page: Page) => { await page.goto("http://localhost:8080"); await page.evaluate( - async ({ rpcUrl, storeName }) => { + async ({ rpcUrl, storeName, feeFaucetId }) => { const sdkExports = await import("./index.js"); for (const [key, value] of Object.entries(sdkExports)) { window[key] = value; } window.rpcUrl = rpcUrl; + // These pages set themselves up from a bare goto, so the global + // fixture never ran on them and window.helpers does not exist here; + // this is the one place the positional list is spelled out twice. const client = await window.WasmWebClient.createClient( rpcUrl, undefined, undefined, - storeName + storeName, + undefined, // logLevel + undefined, // useWorker, defaulted + undefined, // observability + feeFaucetId ); window.client = client; }, - { rpcUrl, storeName: rapidStoreName } + { rpcUrl, storeName: rapidStoreName, feeFaucetId: getFeeFaucetId() } ); }; diff --git a/crates/web-client/test/test-helpers.ts b/crates/web-client/test/test-helpers.ts index 5fd27c64..f7be07e4 100644 --- a/crates/web-client/test/test-helpers.ts +++ b/crates/web-client/test/test-helpers.ts @@ -992,7 +992,11 @@ export async function createMidenClient(sdk: any): Promise { rpcUrl?: string, noteTransportUrl?: any, seed?: any, - storeName?: string + storeName?: string, + _logLevel?: unknown, + _useWorker?: unknown, + _observability?: unknown, + feeFaucetId?: string ) => { const dir = tmpDir(); const client = new rawSdk.WebClient(); @@ -1002,7 +1006,10 @@ export async function createMidenClient(sdk: any): Promise { norm(seed) ?? null, path.join(dir, `${storeName || "store"}.db`), path.join(dir, "keystore"), - false + // Forward what the caller passed; the environment is only the fixture + // default. Reconstructing it here meant deleting `options?.feeFaucetId` + // from client.js failed no node-mode test. + feeFaucetId ?? process.env.TEST_MIDEN_FEE_FAUCET_ID ?? null ); return wrapClientForMidenClient(client, rawSdk, storeName); }, @@ -1051,9 +1058,24 @@ export async function createIntegrationClient(): Promise<{ } | null> { const rpcUrl = getRpcUrl(); const storeName = `integration_${RUN_ID}_${++_integrationCounter}`; + // `null` from here means "no node reachable", and callers turn that into + // test.skip. A misconfiguration must not be able to borrow that meaning: the + // browser twin swallowed a missing fee faucet once and turned 12 integration + // tests into silent skips while their shards reported success. + if (!process.env.TEST_MIDEN_FEE_FAUCET_ID) { + throw new Error( + "TEST_MIDEN_FEE_FAUCET_ID is unset - a client without a fee faucet " + + "cannot execute or screen notes on any 0.17 network" + ); + } try { return await createNodeIntegrationClient(rpcUrl, storeName); - } catch { + } catch (err) { + // Keep the skip for an unreachable node, but leave a trace: a future cause + // is otherwise invisible in the run output. + console.debug( + `integration client unavailable: ${(err as Error)?.message ?? err}` + ); return null; } } diff --git a/crates/web-client/test/test-setup.ts b/crates/web-client/test/test-setup.ts index 77e26f70..46a83a62 100644 --- a/crates/web-client/test/test-setup.ts +++ b/crates/web-client/test/test-setup.ts @@ -26,7 +26,12 @@ import { createRequire } from "module"; import path from "path"; import fs from "fs"; import os from "os"; -import { getRpcUrl, getProverUrl, RUN_ID } from "./playwright.global.setup"; +import { + getRpcUrl, + getProverUrl, + getFeeFaucetId, + RUN_ID, +} from "./playwright.global.setup"; const require = createRequire(import.meta.url); @@ -126,7 +131,7 @@ export async function createNodeIntegrationClient( null, path.join(tmpDir, `${storeName}.db`), path.join(tmpDir, "keystore"), - false + process.env.TEST_MIDEN_FEE_FAUCET_ID ?? null ); const client = wrapNodeClient(rawClient, rawSdk); @@ -441,11 +446,14 @@ async function getRunBrowser(projectName: string): Promise { async function setupBrowserPage(page: any, testInfo: TestInfo) { const rpcUrl = getRpcUrl(); const storeName = generateStoreName(testInfo); + // Every non-mock client this page builds needs it, and the page cannot read + // the environment itself - it has to travel through the evaluate payload. + const feeFaucetId = getFeeFaucetId(); await page.goto("http://localhost:8080"); await page.evaluate( - async ({ rpcUrl, storeName }) => { + async ({ rpcUrl, storeName, feeFaucetId }) => { // Import all SDK exports and attach to window const sdkExports = await import("./index.js"); for (const [key, value] of Object.entries(sdkExports)) { @@ -469,6 +477,7 @@ async function setupBrowserPage(page: any, testInfo: TestInfo) { } window.client = client; window.rpcUrl = rpcUrl; + window.feeFaucetId = feeFaucetId; window.storeName = storeName; // ── Register helpers on window ────────────────────────────── @@ -1142,16 +1151,37 @@ async function setupBrowserPage(page: any, testInfo: TestInfo) { }, createIntegrationClient: async () => { + // `null` from here means "no node reachable", and callers turn that + // into test.skip. A misconfigured fixture must not be able to borrow + // that meaning: without a fee faucet the client cannot be built at + // all, and swallowing that once turned 12 integration tests into + // silent skips while their shards still reported success. + if (!window.feeFaucetId) { + throw new Error( + "integration fixture is missing window.feeFaucetId - set it in " + + "setupBrowserPage from getFeeFaucetId(); a client without one " + + "cannot execute or screen notes on any 0.17 network" + ); + } try { const uniqueName = `int_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const client = await window.WasmWebClient.createClient( window.rpcUrl, undefined, undefined, - uniqueName + uniqueName, + undefined, // logLevel + undefined, // useWorker, defaulted + undefined, // observability + window.feeFaucetId ); return { client }; - } catch { + } catch (err) { + // Keep the skip, but leave a trace: a future cause other than an + // unreachable node is otherwise invisible in the run output. + console.debug( + `integration client unavailable: ${err?.message ?? err}` + ); return null; } }, @@ -1162,7 +1192,7 @@ async function setupBrowserPage(page: any, testInfo: TestInfo) { getRpcUrl: () => window.rpcUrl, }; }, - { rpcUrl, storeName } + { rpcUrl, storeName, feeFaucetId } ); } diff --git a/docs/external/src/react-client/library/use-chain-anchor.md b/docs/external/src/react-client/library/use-chain-anchor.md index 99c75081..2f356a85 100644 --- a/docs/external/src/react-client/library/use-chain-anchor.md +++ b/docs/external/src/react-client/library/use-chain-anchor.md @@ -91,15 +91,20 @@ const buildRequest = async (client) => ``` The argument is the account that **executes** the request — the multisig here, -not a recipient. On a zero-fee chain the builder comes back untouched, so this is -a safe drop-in. Requests produced by the `new*TransactionRequest` constructors +not a recipient. For an account that is not a multisig the builder comes back +untouched, so this is a safe drop-in; a zero base fee is not a second condition, +since 0.17 a multisig resolves its auth args whatever the chain charges. Requests produced by the `new*TransactionRequest` constructors already declare a salt and need nothing extra. Two caveats specific to this flow. `withAuthArg` and `withFeeConversionSalt` occupy the same slot and each setter clears the other, so a request cannot carry -both — call whichever you actually want last. And because the salt is drawn per +both. Never call either on a builder from `feeAwareTransactionRequestBuilder` for a multisig: that builder already carries the three-word auth args, and either setter discards them, so the transaction aborts in the auth procedure. Pass `feeConversionSalt` to `feeAwareTransactionRequestBuilder` instead. And because the salt and the bound block are chosen per build, the warning below about resolving a factory exactly once applies here too -— capture the anchor, then preview and execute against `anchoredRequest`. +— capture the anchor, then preview and execute against `anchoredRequest`. A +co-signer rebuilding the proposal instead of receiving its bytes passes +`feeConversionSalt` and `boundBlockNum`, or the two summaries cannot match. +Each call consumes the `Word`, so a second build needs a freshly constructed +one; a spent handle arrives as "no salt given" and one is drawn instead. ## Verifying and co-signing diff --git a/docs/external/src/react-client/library/use-create-network-note.md b/docs/external/src/react-client/library/use-create-network-note.md index 1d7056c5..c5c58847 100644 --- a/docs/external/src/react-client/library/use-create-network-note.md +++ b/docs/external/src/react-client/library/use-create-network-note.md @@ -59,6 +59,18 @@ confirm classification. | `assetId` / `amount` | `AccountRef` / `bigint \| number` | Optional single asset to lock into the note. | | `attachment` | `bigint[] \| Uint8Array \| number[]` | Extra attachment payload appended after the required `NetworkAccountTarget`. | +## The 20-block window + +Pricing the note calls `estimate_note_fee` on the target account, and that +procedure applies the standards' default expiration delta, so the transaction +this hook submits must be included within **20 blocks** of its reference block — +about a minute at a three-second block interval. An expiration can only be +lowered, never raised, so nothing can widen it. + +If a slow prove makes the node reject the submission as expired, sync and then +call `createNetworkNote` again. Calling it again on its own is not enough: the +hook does not sync, so it would rebuild against the same reference block. + ## See also - [Network notes](../../web-client/library/network-notes.md) — the underlying `MidenClient` resource method and concept overview. diff --git a/docs/external/src/web-client/library/network-notes.md b/docs/external/src/web-client/library/network-notes.md index 5ae2c3ad..09a209d3 100644 --- a/docs/external/src/web-client/library/network-notes.md +++ b/docs/external/src/web-client/library/network-notes.md @@ -13,7 +13,7 @@ custom consumption script: ```typescript import { MidenClient } from "@miden-sdk/miden-sdk"; -const client = await MidenClient.create(); +const client = await MidenClient.create({ feeFaucetId: FEE_FAUCET }); const req = await client.transactions.createNetworkNote({ account: senderId, @@ -31,6 +31,8 @@ returns `{ txId, note, result }`. Provide exactly one of `script` or `recipient` — passing both, or neither, throws. Notes are always Public — the attachment, not the tag, is what a network account matches on. +Pricing the note calls `estimate_note_fee` on the target, and that procedure applies the standards' default expiration delta, so the emitting transaction must be included within **20 blocks** of its reference block - about a minute at a three-second block interval. An expiration can only be lowered, never raised, so this cannot be widened: if proving is slow enough that the node rejects the submission as expired, re-execute against a fresh reference block and submit again. + ## Targeting a network account `target` accepts an account reference (hex/bech32 id or `AccountId`), or a @@ -118,14 +120,19 @@ import { AccountComponent, AccountStorageMode, NoteScriptFee, - TransactionRequestBuilder, } from "@miden-sdk/miden-sdk"; // Reuse the same compiled note script when building the network note, so // the allowlisted root matches. // // Each allowed script carries the fee charged to consume it, denominated in -// the fungible asset of `feeFaucetId`. A zero price is valid. +// the chain's fee asset. A zero price is valid. +// +// The fee faucet must be the chain's own. The node refuses to run network +// transactions for an account whose fee asset differs from the chain's +// protocol configuration, and nothing reports it to the client: the account's +// notes are simply never consumed. +const feeFaucetId = await client.feeFaucetId(); const components = AccountComponent.createNetworkAuthComponents( [new NoteScriptFee(noteScript.root(), 0n)], feeFaucetId @@ -141,16 +148,19 @@ const { account } = builder.build(); await client.accounts.insert({ account }); -// The auth component bumps the nonce itself, so a scriptless transaction -// commits the account on-chain. The bare builder is right here even on a -// fee-charging chain: the network-account auth component pays the fee from the -// chain's native conversion info rather than the transaction's auth args, so -// there is nothing to attach. See "Which accounts read conversion info" in the -// transactions guide. -await client.transactions.submit( - account.id(), - new TransactionRequestBuilder().build() -); +// Deploying needs an effect. Since 0.17 the auth component asserts the +// transaction consumed an input note, created an output note, or changed the +// account state BEFORE it pays the fee, so an empty transaction aborts with +// `network account transactions must have an effect before fee payment`. +// Consuming a note whose script the account allowlists is the cheapest one. +// +// The bare builder is still right here on the fee side: the network-account +// auth component pays from the chain's native conversion info rather than the +// transaction's auth args, so there is nothing to attach. See "Which accounts +// read conversion info" in the transactions guide. +// `allowlistedNoteId` is a note already sent to the account whose script root +// is in the allowlist above. +await client.transactions.consume(account.id(), [allowlistedNoteId]); ``` The allowlist must be non-empty (`createNetworkAuthComponents([], ...)` throws). diff --git a/docs/external/src/web-client/library/transactions.md b/docs/external/src/web-client/library/transactions.md index 34e504eb..1bbdd6eb 100644 --- a/docs/external/src/web-client/library/transactions.md +++ b/docs/external/src/web-client/library/transactions.md @@ -13,7 +13,7 @@ This guide demonstrates how to send, batch, and retrieve transactions using the import { MidenClient } from "@miden-sdk/miden-sdk"; try { - const client = await MidenClient.create(); + const client = await MidenClient.create({ feeFaucetId: FEE_FAUCET }); // List all transactions const allTransactions = await client.transactions.list(); @@ -53,7 +53,7 @@ try { import { MidenClient } from "@miden-sdk/miden-sdk"; try { - const client = await MidenClient.create(); + const client = await MidenClient.create({ feeFaucetId: FEE_FAUCET }); // Get uncommitted transactions const uncommitted = await client.transactions.list({ status: "uncommitted" }); @@ -195,17 +195,17 @@ So how much of this you have to think about depends on the account: - **Multisig, smart multisig and guarded multisig** — miden-client refuses to guess the salt, and the transaction fails with `FeeConversionInfoRequired` naming the component. Declaring a salt is what makes those accounts work at all. - **A custom auth procedure that reads conversion info** — miden-client does not recognise the component, commits nothing, and the transaction hits the VM abort above. Attach the commitment yourself; see [Custom auth procedures](#custom-auth-procedures). -Whether any of this applies is a property of the chain, and `BlockHeader.verificationBaseFee()` is how you ask. A block header is reachable from a chain anchor, which also names the fee asset the chain prices in: +Whether any of this applies is a property of the chain, and `BlockHeader.verificationBaseFee()` is how you ask. A block header is reachable from a chain anchor; the fee asset itself comes from the client, since 0.17 keeps it in the protocol configuration rather than in the header: ```typescript const anchor = await client.transactions.captureAnchor(request); const header = anchor.blockHeader(); const chargesFees = header.verificationBaseFee() > 0; -const feeFaucet = header.feeFaucetId(); +const feeFaucet = await client.feeFaucetId(); ``` -On a chain that charges nothing, requests are byte-identical to what earlier versions produced — no auth argument, no declared salt, no advice entry. +On a chain that charges nothing, a request for an account that is not a multisig is byte-identical to what earlier versions produced — no auth argument, no declared salt, no advice entry. A multisig is the exception at any base fee: since 0.17 its auth procedure resolves its auth args unconditionally, so the request carries them even on a fee-free chain. ### The convenience constructors handle it @@ -224,25 +224,32 @@ const builder = await client.feeAwareTransactionRequestBuilder(wallet); const request = builder.withCustomScript(script).build(); ``` -`feeAwareTransactionRequestBuilder` takes the account that will **execute** the request — the one whose auth procedure pays the fee — not the recipient or the note's sender. It is a safe drop-in for `new TransactionRequestBuilder()`: on a zero-fee chain, or for any account that does not choose its own salt, it returns an untouched builder. +`feeAwareTransactionRequestBuilder` takes the account that will **execute** the request — the one whose auth procedure pays the fee — not the recipient or the note's sender. It is a safe drop-in for `new TransactionRequestBuilder()`: for an account that is not a multisig it returns an untouched builder. A zero base fee is not a second condition — since 0.17 a multisig resolves its auth args whatever the chain charges. To set the salt yourself — which co-signers must do when they need to agree on it without transporting the proposer's request bytes — declare it directly: ```typescript import { TransactionRequestBuilder, Word } from "@miden-sdk/miden-sdk"; -// Co-signers must all derive the same summary, so they must agree on this. +// Co-signers must all derive the same summary, so they must agree on both the +// salt and the block it binds. const salt = new Word([1n, 2n, 3n, 4n]); -const request = new TransactionRequestBuilder() - .withFeeConversionSalt(salt) +const request = ( + await client.feeAwareTransactionRequestBuilder(multisig, { + feeConversionSalt: salt, + boundBlockNum: agreedBlock, + }) +) .withCustomScript(script) .build(); ``` +Each call **consumes** the `Word` you pass: it is moved across the WASM boundary, so a second call needs a freshly built one. Reusing a spent handle is not an error - it arrives as "no salt given" and one is drawn for you, which is the divergence pinning the salt exists to prevent. + The salt is a *declaration*, not a commitment: `request.feeConversionSalt()` reports it back, `request.authArg()` is still empty, and miden-client computes `hash(CONVERSION_INFO || SALT)` from it during preparation. It survives serialization, so a proposal transported to its co-signers still names the salt its summary was derived under. -`withAuthArg` and `withFeeConversionSalt` are **mutually exclusive**, and miden-client enforces that by having each setter clear the other — so whichever you call last simply wins, rather than producing an error. +`withAuthArg` and `withFeeConversionSalt` are **mutually exclusive**, and miden-client enforces that by having each setter clear the other — so whichever you call last simply wins, rather than producing an error. That makes either setter destructive on a builder from `feeAwareTransactionRequestBuilder` for a multisig: it already carries the component's three-word auth args, and clearing them leaves the auth procedure piping a preimage that was never written. Pass `feeConversionSalt` to the builder instead, as above. ### Custom auth procedures diff --git a/packages/react-sdk/AGENTS.md b/packages/react-sdk/AGENTS.md index 4c4d4435..052238df 100644 --- a/packages/react-sdk/AGENTS.md +++ b/packages/react-sdk/AGENTS.md @@ -28,19 +28,27 @@ import { MidenProvider } from "@miden-sdk/react"; function App() { return ( - + ); } ``` +`feeFaucetId` is required today. Since 0.17 the chain's fee asset lives in a +protocol configuration the node does not serve over RPC, and the SDK carries a +per-network default for no network yet, so a provider without it fails at client +init. It is the faucet the chain mints its fee asset from - ask whoever runs the +network, or read it from the genesis of a local node. Other snippets in this file +leave it out to keep the point they make legible; every real provider needs it. + ## Configuration ```tsx + ); @@ -154,6 +154,12 @@ function App() { // RPC endpoint (defaults to testnet). You can also use 'devnet' or 'testnet'. rpcUrl: 'devnet', + // REQUIRED: the faucet the chain mints its fee asset from, bech32 or hex. + // Since 0.17 the fee asset lives in a protocol configuration the node does + // not serve over RPC, and the SDK carries a default for no network yet, so + // a provider without this fails at client init. + feeFaucetId: FEE_FAUCET, + // Auto-sync interval in milliseconds (default: 15000) // Set to 0 to disable auto-sync autoSyncInterval: 15000, diff --git a/packages/react-sdk/skills/react-sdk-patterns/SKILL.md b/packages/react-sdk/skills/react-sdk-patterns/SKILL.md index cd28a2a9..75ea0dbe 100644 --- a/packages/react-sdk/skills/react-sdk-patterns/SKILL.md +++ b/packages/react-sdk/skills/react-sdk-patterns/SKILL.md @@ -725,8 +725,7 @@ For compile-from-source, call `await client.createCodeBuilder()` (returns `Promi Some `@miden-sdk/miden-sdk` 0.16.1 additions have **no** `@miden-sdk/react` hook or type. To use them, build the `TransactionRequest` yourself against `useMidenClient()` and hand it to `useTransaction().execute({ accountId, request })`: -- `ForeignAccount.private(account)` and `ForeignAccount.prefetched(inputs)`, plus the `AccountInputs` model. `useExecuteProgram()`'s `foreignAccounts` option only builds `ForeignAccount.public(id, storage)`, so a private or prefetched foreign account has to go the manual route. -- `client.transactions.foreignAccountInputs(accounts, blockNum)`, which fetches the inputs to feed `prefetched`. +- `ForeignAccount.private(account)`. `useExecuteProgram()`'s `foreignAccounts` option only builds `ForeignAccount.public(id, storage)`, so a private foreign account has to go the manual route. - `TransactionRequestBuilder.withExplicitInputNote(note, args?)`, which pins whether each input note is consumed authenticated or unauthenticated so every client executing the request produces the same transaction summary. This matters most in chain-anchored flows, where co-signers must reproduce the summary exactly. ## Account Import then Sync then Read Storage Flow diff --git a/packages/react-sdk/src/__tests__/context/MidenProvider.coverage.test.tsx b/packages/react-sdk/src/__tests__/context/MidenProvider.coverage.test.tsx index b98d42d9..128ee1c1 100644 --- a/packages/react-sdk/src/__tests__/context/MidenProvider.coverage.test.tsx +++ b/packages/react-sdk/src/__tests__/context/MidenProvider.coverage.test.tsx @@ -7,6 +7,8 @@ import { useMidenClient, } from "../../context/MidenProvider"; import { useMidenStore } from "../../store/MidenStore"; +import { SignerContext } from "../../context/SignerContext"; +import { createMockSignerContext } from "../mocks/signer-context"; // Coverage-targeted tests for branches that the main MidenProvider.test.tsx // doesn't exercise (custom loading/error UI, init failure path, useMidenClient @@ -17,6 +19,69 @@ beforeEach(() => { vi.clearAllMocks(); }); +describe("MidenProvider — fee faucet", () => { + it("passes config.feeFaucetId to createClient as its last argument", async () => { + // A 0.17 client cannot execute or screen notes without a protocol + // configuration, and this is the only thing the provider is given to build + // one from, so a dropped argument leaves every consumer of the provider + // unable to transact. + render( + +
ready
+
+ ); + + await waitFor(() => { + expect(vi.mocked(WebClient.createClient)).toHaveBeenCalled(); + }); + // Length plus absolute index, not a position from the end: passing the + // value last makes `args[args.length - 1]` true by construction, so + // deleting an earlier placeholder would shift every later argument one slot + // left and still satisfy it. + const args = vi.mocked(WebClient.createClient).mock.calls[0]; + expect(args).toHaveLength(8); + expect(args[7]).toBe("0x1234567890abcdef"); + }); + + // The branch every signer provider takes, and the harder of the two: eleven + // positional arguments with `undefined` placeholders, which is exactly what + // rots. The createClient case above cannot catch a regression here. + it("passes config.feeFaucetId to the external-keystore factory as its last argument", async () => { + const signer = createMockSignerContext({ + isConnected: true, + storeName: "signer_fee_faucet", + }); + + render( + + +
ready
+
+
+ ); + + await waitFor(() => { + expect( + vi.mocked(WebClient.createClientWithExternalKeystore) + ).toHaveBeenCalled(); + }); + const args = vi.mocked(WebClient.createClientWithExternalKeystore).mock + .calls[0]; + expect(args).toHaveLength(11); + expect(args[10]).toBe("0xfeedfacecafebeef"); + }); +}); + describe("MidenProvider — custom loading + error rendering", () => { it("renders the loadingComponent ReactNode while initializing", async () => { // Make createClient hang so isInitializing stays true long enough to diff --git a/packages/react-sdk/src/__tests__/hooks/useCreateNetworkNote.test.tsx b/packages/react-sdk/src/__tests__/hooks/useCreateNetworkNote.test.tsx index f73d8780..6888de34 100644 --- a/packages/react-sdk/src/__tests__/hooks/useCreateNetworkNote.test.tsx +++ b/packages/react-sdk/src/__tests__/hooks/useCreateNetworkNote.test.tsx @@ -122,6 +122,16 @@ describe("useCreateNetworkNote", () => { expect((executingAccount as { toString(): string }).toString()).toBe( "0xsender" ); + + // Since 0.17 the kernel prices a NetworkAccountTarget note by calling + // `estimate_note_fee` on the target, so the emitting transaction declares + // it as a foreign account. Asserting the call alone would pass on an empty + // array, so read back what was pushed. + const builder = + await mockClient.feeAwareTransactionRequestBuilder.mock.results[0].value; + expect(builder.withForeignAccounts).toHaveBeenCalledTimes(1); + const [declared] = builder.withForeignAccounts.mock.calls[0]; + expect((declared as { pushed: unknown[] }).pushed).toHaveLength(1); }); it("builds a note from a provided recipient (no script)", async () => { diff --git a/packages/react-sdk/src/__tests__/mocks/miden-sdk.ts b/packages/react-sdk/src/__tests__/mocks/miden-sdk.ts index c2bdaa9c..7d4b4d35 100644 --- a/packages/react-sdk/src/__tests__/mocks/miden-sdk.ts +++ b/packages/react-sdk/src/__tests__/mocks/miden-sdk.ts @@ -398,6 +398,7 @@ export const createMockWebClient = ( feeAwareTransactionRequestBuilder: vi.fn().mockImplementation(async () => { const builder = { withOwnOutputNotes: vi.fn(() => builder), + withForeignAccounts: vi.fn(() => builder), withInputNotes: vi.fn(() => builder), withCustomScript: vi.fn(() => builder), build: vi.fn(() => createMockTransactionRequest()), diff --git a/packages/react-sdk/src/__tests__/setup.ts b/packages/react-sdk/src/__tests__/setup.ts index 8ae2e2fd..fe23895b 100644 --- a/packages/react-sdk/src/__tests__/setup.ts +++ b/packages/react-sdk/src/__tests__/setup.ts @@ -52,6 +52,7 @@ vi.mock("@miden-sdk/miden-sdk", () => { feeAwareTransactionRequestBuilder: vi.fn().mockImplementation(async () => { const builder = { withOwnOutputNotes: vi.fn(() => builder), + withForeignAccounts: vi.fn(() => builder), withInputNotes: vi.fn(() => builder), withCustomScript: vi.fn(() => builder), build: vi.fn(() => ({})), @@ -398,6 +399,7 @@ vi.mock("@miden-sdk/miden-sdk", () => { }, TransactionRequestBuilder: class TransactionRequestBuilder { withOwnOutputNotes = vi.fn(() => this); + withForeignAccounts = vi.fn(() => this); withInputNotes = vi.fn(() => this); build = vi.fn(() => ({})); }, @@ -423,7 +425,15 @@ vi.mock("@miden-sdk/miden-sdk", () => { ), }), ForeignAccountArray: class ForeignAccountArray { - constructor(_accounts?: unknown[]) {} + // Records pushes: a test asserting a target was declared needs to read + // back what went in, not just that the array was constructed. + pushed: unknown[] = []; + constructor(accounts?: unknown[]) { + if (Array.isArray(accounts)) this.pushed = [...accounts]; + } + push(account: unknown) { + this.pushed.push(account); + } }, AccountStorageRequirements: class AccountStorageRequirements {}, NoteFilter: vi.fn().mockImplementation((_type: unknown, ids?: unknown) => { diff --git a/packages/react-sdk/src/context/MidenProvider.tsx b/packages/react-sdk/src/context/MidenProvider.tsx index b391dd8f..c1a1f728 100644 --- a/packages/react-sdk/src/context/MidenProvider.tsx +++ b/packages/react-sdk/src/context/MidenProvider.tsx @@ -265,7 +265,9 @@ export function MidenProvider({ signerContext.insertKeyCb, wrappedSignCb, undefined, - resolvedConfig.useWorker + resolvedConfig.useWorker, + undefined, + resolvedConfig.feeFaucetId ); if (cancelled) return; @@ -291,7 +293,9 @@ export function MidenProvider({ seed, undefined, undefined, - resolvedConfig.useWorker + resolvedConfig.useWorker, + undefined, + resolvedConfig.feeFaucetId ); if (cancelled) return; } diff --git a/packages/react-sdk/src/hooks/useCreateNetworkNote.ts b/packages/react-sdk/src/hooks/useCreateNetworkNote.ts index 4eb60024..42c64e4d 100644 --- a/packages/react-sdk/src/hooks/useCreateNetworkNote.ts +++ b/packages/react-sdk/src/hooks/useCreateNetworkNote.ts @@ -1,8 +1,11 @@ import { useCallback, useState } from "react"; import { useMiden } from "../context/MidenProvider"; import { + AccountStorageRequirements, Felt, FeltArray, + ForeignAccount, + ForeignAccountArray, FungibleAsset, Note, NoteArray, @@ -122,9 +125,28 @@ export function useCreateNetworkNote(): UseCreateNetworkNoteResult { // The sender executes this transaction, so its auth procedure is what // pays the fee; a bare builder would abort with // ERR_FEE_CONVERSION_INFO_MISSING wherever the chain charges. + // Since 0.17 the kernel prices a NetworkAccountTarget note by calling + // `estimate_note_fee` on the target, so declare it as a foreign + // account rather than relying on the client resolving it lazily. + // That pricing call also caps this transaction at 20 blocks from its + // reference block; on an expiry rejection the caller must sync before + // retrying, since neither this hook nor the resource syncs first. + // `client.transactions.createNetworkNote` does the same; this hook + // builds its request itself, so it owns the declaration. + const targetAccounts = new ForeignAccountArray(); + targetAccounts.push( + ForeignAccount.public( + target.targetId(), + new AccountStorageRequirements() + ) + ); + const builder = await client.feeAwareTransactionRequestBuilder(senderId); - const txRequest = builder.withOwnOutputNotes(ownOutputs).build(); + const txRequest = builder + .withOwnOutputNotes(ownOutputs) + .withForeignAccounts(targetAccounts) + .build(); // Reuse `senderId` (NoteMetadata only borrows it) rather than // re-parsing the same account id for submission. diff --git a/packages/react-sdk/src/types/index.ts b/packages/react-sdk/src/types/index.ts index 79253e1c..44092e8b 100644 --- a/packages/react-sdk/src/types/index.ts +++ b/packages/react-sdk/src/types/index.ts @@ -106,6 +106,14 @@ export interface MidenConfig { rpcUrl?: RpcUrlConfig; /** Note transport URL for streaming notes. */ noteTransportUrl?: string; + /** + * Faucet of the chain's fee asset, as a bech32 address or a hex account ID. + * + * Required for a network the SDK knows no fee faucet for. Since 0.17 the fee asset lives in + * the protocol configuration rather than the block header, and a client that cannot build one + * can neither execute nor screen notes. + */ + feeFaucetId?: string; /** Auto-sync interval in milliseconds. Set to 0 to disable. Default: 15000ms */ autoSyncInterval?: number; /** Initial seed for deterministic RNG (must be 32 bytes if provided) */ diff --git a/packages/telemetry-otel/AGENTS.md b/packages/telemetry-otel/AGENTS.md index 17d5b1ec..c4067b26 100644 --- a/packages/telemetry-otel/AGENTS.md +++ b/packages/telemetry-otel/AGENTS.md @@ -22,6 +22,7 @@ option across. ```ts const client = await MidenClient.create({ rpcUrl: "testnet", + feeFaucetId: FEE_FAUCET, observer: createOtelObserver({ tracer: trace.getTracer("my-app") }), }); ``` diff --git a/packages/telemetry-otel/README.md b/packages/telemetry-otel/README.md index 2dd5e433..18ace341 100644 --- a/packages/telemetry-otel/README.md +++ b/packages/telemetry-otel/README.md @@ -30,6 +30,7 @@ import { createOtelObserver } from "@miden-sdk/telemetry-otel"; const client = await MidenClient.create({ rpcUrl: "testnet", + feeFaucetId: FEE_FAUCET, observer: createOtelObserver({ tracer: trace.getTracer("my-app") }), }); ``` diff --git a/packages/telemetry-sentry/AGENTS.md b/packages/telemetry-sentry/AGENTS.md index ddc9399f..680b76f6 100644 --- a/packages/telemetry-sentry/AGENTS.md +++ b/packages/telemetry-sentry/AGENTS.md @@ -21,6 +21,7 @@ guard, so do not reason from one package to the other. ```ts const client = await MidenClient.create({ rpcUrl: "testnet", + feeFaucetId: FEE_FAUCET, observer: createSentryObserver({ client: Sentry, minDurationMs: 5_000 }), }); ``` diff --git a/packages/telemetry-sentry/README.md b/packages/telemetry-sentry/README.md index 376dcc5d..afe851f3 100644 --- a/packages/telemetry-sentry/README.md +++ b/packages/telemetry-sentry/README.md @@ -30,6 +30,7 @@ Sentry.init({ dsn: "..." }); // yours to call, once, in your entry point const client = await MidenClient.create({ rpcUrl: "testnet", + feeFaucetId: FEE_FAUCET, observer: createSentryObserver({ client: Sentry, minDurationMs: 5_000 }), }); ``` diff --git a/packages/turnkey/core/examples/react/README.md b/packages/turnkey/core/examples/react/README.md index 88d23da1..dcac31a4 100644 --- a/packages/turnkey/core/examples/react/README.md +++ b/packages/turnkey/core/examples/react/README.md @@ -60,14 +60,22 @@ const signCb = (turnkeyConfig: TConfig) => { }; }; -// Create Miden client with external keystore (Turnkey) +// Create Miden client with external keystore (Turnkey). +// The list is positional, so every skipped slot needs its own `undefined`: +// (rpcUrl, noteTransportUrl, seed, storeName, getKeyCb, insertKeyCb, signCb, +// logLevel, useWorker, observability, feeFaucetId) const webClient = await WebClient.createClientWithExternalKeystore( endpoint, noteTransportUrl, seed, - undefined, - undefined, - signCb(turnkeyConfig) // Turnkey signing callback + undefined, // storeName + undefined, // getKeyCb + undefined, // insertKeyCb + signCb(turnkeyConfig), // Turnkey signing callback + undefined, // logLevel + undefined, // useWorker + undefined, // observability + feeFaucetId // required: the chain's fee faucet, bech32 or hex ); ``` diff --git a/packages/vite-plugin/skills/vite-wasm-setup/SKILL.md b/packages/vite-plugin/skills/vite-wasm-setup/SKILL.md index 3e7c9c83..8289e1f5 100644 --- a/packages/vite-plugin/skills/vite-wasm-setup/SKILL.md +++ b/packages/vite-plugin/skills/vite-wasm-setup/SKILL.md @@ -48,7 +48,7 @@ import { MidenClient, initThreadPool } from "@miden-sdk/miden-sdk/mt/lazy"; await MidenClient.ready(); await initThreadPool(navigator.hardwareConcurrency); // same realm as the direct MT client -const client = await MidenClient.create({ useWorker: false }); +const client = await MidenClient.create({ useWorker: false, feeFaucetId: FEE_FAUCET }); ``` The ST entries don't expose `initThreadPool`, because there is no pool to bring up. diff --git a/tools/strip-masp-debug/Cargo.toml b/tools/strip-masp-debug/Cargo.toml index 1f603866..f9ef6488 100644 --- a/tools/strip-masp-debug/Cargo.toml +++ b/tools/strip-masp-debug/Cargo.toml @@ -9,9 +9,11 @@ repository.workspace = true rust-version.workspace = true version.workspace = true -# Being a workspace member keeps these pinned to the exact versions the -# WASM build embeds (single Cargo.lock), so the package format this tool -# parses can never drift from the format inside the binary it rewrites. +# These must name the VM version the WASM build embeds. Workspace membership alone does not +# keep them there: a requirement of "0.29" against a client on 0.33 simply resolves a second, +# older copy, and the tool then parses a format the binary no longer contains - it strips +# nothing, exits 1, wasm-opt is skipped, and the artifact ships at several times its budget. +# Bump these with `miden-client`. [dependencies] -miden-core = { default-features = false, features = ["std"], version = "0.29" } -miden-mast-package = { default-features = false, features = ["std"], version = "0.29" } +miden-core = { default-features = false, features = ["std"], version = "0.33" } +miden-mast-package = { default-features = false, features = ["std"], version = "0.33" } diff --git a/tools/strip-masp-debug/src/main.rs b/tools/strip-masp-debug/src/main.rs index ce3c8548..cd80cf0e 100644 --- a/tools/strip-masp-debug/src/main.rs +++ b/tools/strip-masp-debug/src/main.rs @@ -80,12 +80,14 @@ fn strip_file(path: &Path) -> Result<(usize, usize), String> { let orig_len = cursor.position() as usize; search_from = off + orig_len; - let original_digest = pkg.digest(); + let original_commitment = pkg.dependency_commitment(); let stripped = pkg .without_debug_info() .map_err(|e| format!("package at byte offset {off} failed to strip: {e}"))?; - if stripped.digest() != original_digest { - return Err(format!("package at byte offset {off} changed digest while stripping")); + if stripped.dependency_commitment() != original_commitment { + return Err(format!( + "package at byte offset {off} changed its dependency commitment while stripping" + )); } let lean_len = stripped.to_bytes().len(); if lean_len >= orig_len { @@ -101,8 +103,10 @@ fn strip_file(path: &Path) -> Result<(usize, usize), String> { // embedded packages, and describe the same code. let reparsed = Package::read_from_bytes_trusted(&padded) .map_err(|e| format!("stripped package failed to re-parse: {e}"))?; - if reparsed.digest() != original_digest { - return Err(format!("package at byte offset {off} failed digest verification")); + if reparsed.dependency_commitment() != original_commitment { + return Err(format!( + "package at byte offset {off} failed dependency-commitment verification" + )); } data[off..off + orig_len].copy_from_slice(&padded); @@ -175,6 +179,7 @@ mod tests { use miden_core::mast::{BasicBlockNodeBuilder, DenseMastForestBuilder, MastNodeExt}; use miden_core::operations::Operation; + use miden_mast_package::debug_info::PackageDebugInfoBuilder; use miden_mast_package::{ Dependency, PackageExport, @@ -238,11 +243,30 @@ mod tests { .expect("test package should be valid") } + /// Builds a package carrying a `debug_info` section of roughly `debug_len` bytes. + /// + /// The section has to be a real encoding, not filler: reading a package validates its debug + /// section, so junk bytes make the package unreadable and the tool skips it rather than + /// stripping it. The size comes from interned strings, which is what a real package's debug + /// info is mostly made of, in chunks because a single debug string is capped at 4 KiB. They + /// differ from each other because the builder interns by value. fn package_with_debug(name: &str, debug_len: usize) -> Package { + const CHUNK: usize = 1024; + let mut package = empty_package(name); + let mut builder = PackageDebugInfoBuilder::default(); + let mut written = 0usize; + let mut chunk_index = 0usize; + while written < debug_len { + let len = CHUNK.min(debug_len - written); + builder.add_string(format!("{chunk_index:04}{}", "d".repeat(len.saturating_sub(4)))); + written += len; + chunk_index += 1; + } + let debug_info = *builder.build(); package .sections - .push(Section::new(SectionId::DEBUG_INFO, vec![0x5a; debug_len])); + .push(Section::new(SectionId::DEBUG_INFO, debug_info.to_bytes())); package } @@ -250,12 +274,23 @@ mod tests { fn strips_multiple_packages_and_preserves_layout_and_digests() { let first = package_with_debug("first", 200); let second = package_with_debug("second", 20_000); - let first_digest = first.digest(); - let second_digest = second.digest(); + let first_commitment = first.dependency_commitment(); + let second_commitment = second.dependency_commitment(); + // The strip's safety check is "this commitment is unchanged", which only means anything + // if the commitment can tell two packages apart. Both fixtures are dependency-free, so a + // value derived from the dependency list alone would be constant here and every + // assertion below would hold on a strip that mangled the MAST. + assert_ne!( + first_commitment, second_commitment, + "dependency_commitment does not discriminate between two different packages, so it \ + cannot serve as the strip's identity check" + ); let first_bytes = first.to_bytes(); let second_bytes = second.to_bytes(); - let first_lean_len = Package::read_from_bytes(&first_bytes).unwrap().to_bytes().len(); - let second_lean_len = Package::read_from_bytes(&second_bytes).unwrap().to_bytes().len(); + // The length the tool pads back up to: a round-trip through the reader keeps every + // section, so the stripped length has to be asked for explicitly. + let first_lean_len = first.clone().without_debug_info().unwrap().to_bytes().len(); + let second_lean_len = second.clone().without_debug_info().unwrap().to_bytes().len(); let prefix = b"false MASP match before package: MASP-not-a-package"; let separator = b"separator MASP-still-not-a-package"; @@ -286,9 +321,10 @@ mod tests { ) .unwrap(); - for (reparsed, digest) in [(reparsed_first, first_digest), (reparsed_second, second_digest)] + for (reparsed, commitment) in + [(reparsed_first, first_commitment), (reparsed_second, second_commitment)] { - assert_eq!(reparsed.digest(), digest); + assert_eq!(reparsed.dependency_commitment(), commitment); assert!(!reparsed.sections.iter().any(|section| section.id.is_debug())); // The padding id may gain trailing '-' bytes when a package's length lands on a // varint-framing gap (see pad_to_len), so match the prefix, not the exact string. @@ -313,13 +349,17 @@ mod tests { let deficits = (100usize..=300).chain(16_380..=16_420); for extra in deficits { let package = empty_package("padding"); - let digest = package.digest(); + let commitment = package.dependency_commitment(); let target = package.to_bytes().len() + extra; let padded = pad_to_len(package, target) .unwrap_or_else(|| panic!("padding should converge for deficit +{extra}")); assert_eq!(padded.len(), target, "wrong length at deficit +{extra}"); let reparsed = Package::read_from_bytes_trusted(&padded).unwrap(); - assert_eq!(reparsed.digest(), digest, "padding changed the digest at deficit +{extra}"); + assert_eq!( + reparsed.dependency_commitment(), + commitment, + "padding changed the commitment at deficit +{extra}" + ); assert!( reparsed.sections.iter().any(|s| s.id.as_str().starts_with(PAD_SECTION_ID)), "padding section missing at deficit +{extra}"