feat(wasm): confidential inputs, fee estimation, tapleaf hash, storage slot width - #157
Merged
Merged
Conversation
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
Arvolear
reviewed
Sep 16, 2026
Arvolear
reviewed
Sep 16, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Confidential wallet inputs
addWalletInputandaddWalletIssuanceInputtake 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.addCovenantInputandaddCovenantIssuanceInputtakederivationPathas well, for the key that fills the signature witness.Before this,
utxo_atsetsecrets: Noneand 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 inestimate_tx.FinalTransaction::has_confidential_inputanswers the input half of that question, next toneeds_blinding.SignerError::ConfidentialInputWithoutBlindedOutput.Signer::estimate_fee, exposed asWalletSigner.estimateFeeReports 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.tapleafHashReturns 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_ataccepted any width, so a shorter value produced a valid address that nothing could spend from. It now assertsProgram::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 warningsandcargo test --workspaceare clean.