Skip to content

feat(wasm): confidential inputs, fee estimation, tapleaf hash, storage slot width - #157

Merged
Arvolear merged 8 commits into
BlockstreamResearch:devfrom
lukachi:feat/state-leaf-width
Sep 17, 2026
Merged

Arvolear merged 8 commits into
BlockstreamResearch:devfrom
lukachi:feat/state-leaf-width

Conversation

@lukachi

@lukachi lukachi commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Confidential wallet inputs

addWalletInput and addWalletIssuanceInput take two optional trailing arguments:

  • blindingSecretsJson: { value, asset, assetBlindingFactor, valueBlindingFactor }, the wallet's own reading of the output it is spending. The builder holds no blinding key, so it cannot work these out itself.
  • derivationPath: the path of the key that spends the input, relative to the account path. Omitting it keeps the signer's default.

addCovenantInput and addCovenantIssuanceInput take derivationPath as well, for the key that fills the signature witness.

Before this, utxo_at set secrets: None and no input could name a path, so a confidential output arrived with nothing to unblind it and every input was signed with the account's first key. Both arguments are trailing and optional, so existing JavaScript is unchanged.

A confidential input stays balanced

A transaction spending a confidential input needs at least one blinded output, or the node rejects it with bad-txns-in-ne-out. This replaces the TODO in estimate_tx.

  • FinalTransaction::has_confidential_input answers the input half of that question, next to needs_blinding.
  • When funds do not stretch to a change output, the estimator no longer drops the change if it is the only blinded output. It reports what covering it costs, so the caller can add an input.
  • An explicit change target with a confidential input and no other blinded output is refused as SignerError::ConfidentialInputWithoutBlindedOutput.

Signer::estimate_fee, exposed as WalletSigner.estimateFee

Reports what a transaction would cost at a given rate, measured from its signed weight. For an unfunded transaction it reports the fee still to be covered, so a caller doing its own coin selection can add inputs until it is.

Covenant.tapleafHash

Returns the tapleaf hash of the covenant's script, alongside the existing commitmentMerkleRoot.

A storage slot is 32 bytes

A slot is hashed into a hidden tapleaf the address commits to, and the contract reads it back in a single 32-byte step. set_storage_at accepted any width, so a shorter value produced a valid address that nothing could spend from. It now asserts Program::STORAGE_SLOT_BYTES. The wasm boundary checks each leaf before calling and returns an error instead of panicking.

Tests

36 SDK tests and 21 wasm tests pass. cargo fmt --check, cargo clippy --workspace --all-targets --all-features -- -D warnings and cargo test --workspace are clean.

A wallet whose coins are blinded could not fund a contract action through these
bindings. `utxo_at` set `secrets: None` and `PartialInput::new` leaves
`derivation_path` at `None`, so a confidential output arrived with nothing to
unblind it and every input was signed with the account's first key.

The SDK already carries both: `PartialInput` documents `secrets` as "if UTXO is
confidential, secrets are Some", and `derivation_path` as what "a wallet whose
UTXOs sit across many derivation indices must set per input, or the signer will
sign with the wrong key". Only the bindings had no way to pass them.

`addWalletInput` and `addWalletIssuanceInput` now take two optional arguments:

- `blindingSecretsJson`, the reading the wallet already made of its own output:
  `{ value, asset, assetBlindingFactor, valueBlindingFactor }`. The builder holds
  no blinding key and cannot work these out for itself.
- `derivationPath`, relative to the account path, for the key that spends the
  input. Omitting it keeps the signer's default, which is what an input at the
  account's first index wants.

Both are optional and trailing, so existing JavaScript keeps working unchanged.

Covenant inputs are left alone. A covenant output can never be blinded — a
Simplicity program reads amounts and asset ids through jets that cannot
introspect a commitment — so it has no secrets to carry.

Claude-Session: https://claude.ai/code/session_01YDzhhRZrpojBkKegSXX6H8
… fee

needs_blinding looks at outputs only, which is half of the question blinding
actually asks. A transaction spending a confidential input needs at least one
blinded output to balance against; without one the node rejects it with
bad-txns-in-ne-out, and nothing in the assembly path noticed.

The estimator was where that surfaced. When funds did not stretch to a change
output it dropped the change, which is exactly the output carrying the blinding
balance. has_confidential_input supplies the missing half, and the estimator now
refuses to drop the change when it is the only blinded output, reporting what
covering it would cost so the caller can bring another input instead.

An explicit change target pinned against a confidential input cannot be funded
into validity at all, so that is named rather than estimated.

estimate_fee answers what a transaction would cost at a given rate. The fee
follows from the signed weight, so a caller selecting its own inputs could only
guess it from per-input and per-output constants, which drift from what the node
charges. It answers for an unfunded transaction too, reporting the fee still to
be covered, so the same call drives a selection loop to completion.

Claude-Session: https://claude.ai/code/session_01YDzhhRZrpojBkKegSXX6H8
The SDK computes it and nothing outside could ask for it. The Commitment Merkle
Root says what the program is; the tapleaf hash says which leaf that program
sits in, which is what a taproot spend commits to and what a signature over the
input covers. A caller identifying a contract wants both, and had only one.

Claude-Session: https://claude.ai/code/session_01YDzhhRZrpojBkKegSXX6H8
A slot is hashed into a hidden tapleaf that the contract's address commits to,
and the contract reads it back in a single 32-byte step. set_storage_at took a
value of any width, so a shorter one produced a perfectly ordinary address that
nothing can ever spend from: the leaf the contract recomputes is not the leaf
the address was built from, and the failure is silent until funds are already
sitting there.

with_storage_capacity already allocates 32-byte slots, so the width was always
the contract, just never enforced. set_storage_at now asserts it, alongside the
bounds check it already had.

The wasm boundary checks before calling rather than relying on that assertion,
since a panic there aborts the caller instead of handing it something it can act
on. Reading the leaves moved into its own function returning the sentence, the
way the other readers here already do, because a JsError constructed off-wasm
aborts and would put the refusal out of reach of a test.

Claude-Session: https://claude.ai/code/session_01YDzhhRZrpojBkKegSXX6H8
Comment thread crates/wasm/src/lib.rs
Comment thread crates/wasm/src/lib.rs Outdated
The signer fills a covenant's signature witness with the key at the input's
derivation path, the same as it does for a wallet input. addCovenantInput and
addCovenantIssuanceInput had no way to set that path, so a covenant held by any
key but the account's first was signed with the wrong one.

Both now take an optional trailing derivationPath, relative to the account path.
Omitting it keeps the signer's default, so existing JavaScript is unchanged.

Building the input and attaching the path now happens in one place for all four
input methods rather than being repeated in each.
state_leaves is only ever used to build a Covenant, so it belongs with it rather than beside it.
@lukachi lukachi changed the title fix(sdk): a storage slot is thirty-two bytes, and nothing else feat(wasm): confidential inputs, fee estimation, tapleaf hash, storage slot width Sep 16, 2026
@Arvolear
Arvolear merged commit 22108d2 into BlockstreamResearch:dev Sep 17, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants