Skip to content

Browser bindings, and the SDK changes a wallet needs to use them - #117

Open
lukachi wants to merge 17 commits into
BlockstreamResearch:masterfrom
lukachi:humid/wasm-wallet-integration
Open

Browser bindings, and the SDK changes a wallet needs to use them#117
lukachi wants to merge 17 commits into
BlockstreamResearch:masterfrom
lukachi:humid/wasm-wallet-integration

Conversation

@lukachi

@lukachi lukachi commented Aug 5, 2026

Copy link
Copy Markdown

Browser bindings for the SDK, and the six SDK changes a wallet needs before it can use them.

This is what it took to make a browser extension perform a contract action end to end: compile a protocol's contracts, derive their addresses, assemble a transaction that spends and creates covenant outputs, and sign it. Everything here was driven by that consumer rather than designed ahead of it, so each change below has a failure behind it.

What is new

crates/wasmwasm-bindgen bindings built with wasm-pack: Contract (compile with parameters and extra taproot leaves, read the CMR, the scriptPubKey and the covenant address), WalletSigner, TransactionBuilder (wallet inputs, covenant inputs, outputs) and SignedTransaction. build.sh drives the build and finds llvm-ar where distributions actually put it rather than at one hardcoded path.

The signer without a provider. Networking backends are behind a provider feature, so a signer can be constructed from a mnemonic and used to sign without any network stack compiled in. A browser has its own.

A supplied change target and a per-input derivation path. finalize_strict takes an optional ChangeTarget, and signing resolves a path per input instead of always 0/0. Without these the signer signs only what happens to sit at one address and sends change somewhere the wallet does not watch. None keeps the previous behaviour.

Three changes that alter what gets committed

These change derived addresses, so they matter beyond the wasm layer.

The tap tree folds left. Upstream balances it. Every covenant address a deployed protocol's funds sit at was derived by the reference implementation, which folds left. The two agree up to three leaves and diverge from four — so a balanced tree derives a well-formed address for a contract whose funds are somewhere else. There is a unit test on the resulting depths.

An extra taproot leaf may be any length. It was constrained to exactly thirty-two bytes; real protocols encode structured payloads of other widths.

The build mode is per program rather than per process. include_debug_symbols wraps tracked expressions in extra Simplicity nodes, which changes the CMR and therefore the address. A protocol declares the mode its contracts were built in, and one process may have to honour two.

Smaller fixes

  • A covenant execution failure names the input index it happened on. Previously a program that did not execute failed without saying where.
  • A caller can say which witness the signer must fill. add_covenant_input accepted a signature witness name that was never passed through, and the binding hardcoded RequiredSignature::None — so a covenant that authenticates its spender could not be signed at all. Found by measuring transaction weights.
  • An input carries the sequence its action declares, for covenants requiring a relative timelock.
  • serde_json recorded in the lockfile.

Verification

cargo test across the workspace, and the wasm package built and driven from the consuming extension's own test suite — 465 tests including the five deployed simplicity-lending contracts compiled through this module with their commitment merkle roots pinned, and transaction weights measured against the real module rather than modelled.

Not verified: no transaction built through these bindings has reached a network yet.

nzafat added 17 commits August 4, 2026 02:23
The Esplora and Elements-RPC backends pull `minreq` and `bitcoincore-rpc`,
neither of which builds for `wasm32-unknown-unknown`. Put them behind a
`provider` feature, on by default, so existing consumers are unaffected and a
host that owns its own networking can build with `--no-default-features`.

The gate is drawn inside the module rather than around it. `SimplicityNetwork`,
`ProviderTrait` and `ProviderError` carry no networking and are used by the
program, transaction and signer code, so they stay unconditional. Only the three
concrete backends, `ProviderInfo` (whose `auth` field is `bitcoincore_rpc::Auth`)
and `ProviderError::Rpc` are feature-gated.

Verified: `cargo build -p smplx-sdk --release --target wasm32-unknown-unknown
--no-default-features` succeeds, and the resulting dependency tree contains no
`minreq`, `bitcoincore-rpc`, `rustls` or `aws-lc`.
Adds `Signer::from_mnemonic(mnemonic, network)`, which builds a signer that can
assemble, blind, sign and finalize a transaction it is handed but cannot discover
UTXOs, look up a fee rate, or broadcast. A host that owns its own networking and
its own coin selection supplies those.

The provider field and every method that reaches it — `new`, `send`, `broadcast`,
`finalize`, `get_provider` and the `get_utxos` family — move behind the existing
`provider` feature. The field becomes an `Option`, so requesting a
provider-backed operation on a signer built without one is a typed error
(`SignerError::ProviderUnavailable`) rather than a panic or a dummy
implementation.

`finalize_strict` now takes the fee rate as a parameter instead of querying a
provider for it. It already assumed coin selection was done by the caller; taking
the fee rate the same way makes the whole signing path run without networking.
No caller in the workspace passed `target_blocks`, so nothing outside this file
changes.

The constructor keeps taking the account mnemonic. That is the whole account
secret, which is wider than this path needs, and it is accepted deliberately —
the doc comment on `from_mnemonic` states the cost, the two rejected
alternatives, and the conditions that should reopen it.

Verified: `cargo check --workspace --all-targets` passes, and
`cargo build -p smplx-sdk --release --target wasm32-unknown-unknown
--no-default-features` produces a 6,707,784-byte rlib, down from 6,767,598.
Two API changes a wallet needs, both of which the signer previously decided for
itself from a single key at `0/0`.

Change output. `finalize_strict` takes an optional `ChangeTarget` — a script and
an optional blinding key — and `estimate_tx` builds the change output from it.
Passing `None` keeps the old behaviour of paying to the signer's own address,
which is only correct for a wallet that watches exactly that one address.

Signing key. `PartialInput` carries an optional derivation path relative to the
account path, and `sign_input` and `sign_program` take it. `get_private_key_at`
derives at that path; `get_private_key` keeps deriving `0/0`, so nothing that
does not set a path changes.

Together these are what a ranged-descriptor wallet needs. Without the first,
change goes to an address the wallet does not watch; without the second, only
UTXOs that happen to sit at `0/0` can be signed and the rest are unspendable on
this path.

Verified: `cargo check --workspace --all-targets` after `cargo clean -p
smplx-sdk`, `cargo check -p smplx-sdk --no-default-features`, and
`cargo build -p smplx-sdk --release --target wasm32-unknown-unknown
--no-default-features` producing a 6,719,300-byte rlib.
Adds `crates/wasm`, a `cdylib` that wraps `smplx-sdk` with wasm-bindgen. It sits
in its own crate so the SDK stays free of wasm-bindgen annotations and the
binding surface is exactly what a host needs, which is the arrangement lwk_wasm
already uses.

The surface is deliberately small: compile a SimplicityHL source delivered at
runtime, return its CMR, and derive the taproot address its funds would sit at.
It grows with the host that consumes it.

Two supporting changes in the SDK:

- `Program` owns its source as `Arc<str>` instead of borrowing a `&'static str`.
  That constraint was never upstream's — `CompiledProgram::new_with_unstable` is
  generic over `Into<Arc<str>>` — so a program whose text arrives at runtime is
  now as ordinary as one baked in at compile time.
- `Program::get_cmr` exposes the Commitment Merkle Root, which was reachable only
  through private helpers. Recomputing it is how a caller establishes for itself
  that a source is the one a deployed protocol used.

`crates/wasm/build.sh` builds the package. It detects a WebAssembly-capable C
compiler and checks the wasm-bindgen CLI against the pinned crate version, so the
two failure modes that produce misleading errors are caught before a full release
build rather than after it. The built `pkg/` is gitignored and rebuilt from the
script.

Verified by executing the module rather than only building it: loaded under Node,
`Contract("fn main() { assert!(jet::eq_32(witness::A, witness::B)); }")` returns
CMR 43041b02608dc3ba245a2e3dc7aa5bc991fcf6c097c6a165a18e97a486461729 — identical
to the value the same source produces when compiled natively — derives a taproot
address, and refuses a malformed source. The wasm module is 4,602,084 bytes after
wasm-bindgen.
Three things wasm-bindgen alone did not do. wasm-pack runs wasm-opt, which takes
the module from 4,602,084 to 2,378,077 bytes; it emits the package.json a
`file:` dependency needs; and it resolves a wasm-bindgen matching the crate
instead of requiring the host's CLI to already agree on the bindgen schema.

The wasm-capable C compiler detection stays, since that failure reports itself as
a problem inside secp256k1-sys rather than as a missing toolchain.

wasm-opt does not change what the module computes: the optimised build returns the
same CMR for the same source as the unoptimised one and as a native build.
A covenant address is derived from a contract source *and* the parameters it was
built with, so a binding that could only compile parameterless contracts could not
derive any real protocol's address.

Arguments cross as JSON in SimplicityHL's own `.args` shape —
{"NAME": {"value": "0x…", "type": "Pubkey"}} — which `Arguments` already
deserializes, so the format is the compiler's rather than one invented here. They
are parsed when the contract is constructed, so a malformed set is refused there
instead of at the moment an address is being derived.
Follows the crates/wasm dependency added for parsing SimplicityHL argument JSON.
Binds Signer::from_mnemonic and the four values a caller needs before a
transaction exists: the plain and confidential addresses, the x-only key a
covenant locking to "the wallet's key" is parameterised with, and the blinding
key an output pays to.

The doc comment states plainly what the object holds — the whole account secret,
for as long as it lives — and points at the accepted-debt record rather than
saying TODO. Callers are told to free() it when the action is done instead of
leaving it in the wasm heap.

Assembly and signing are not bound yet; this is the key material only.
A wallet input crosses as an outpoint plus the consensus-encoded output it spends,
and nothing secret crosses per input: the signer holds the account mnemonic, hence
the SLIP77 master key, hence it can unblind the wallet's own confidential outputs
itself. That is the one place the accepted debt pays for itself — an extended
private key would carry no SLIP77 material and every confidential input would have
to be handed its secrets.

Coin selection stays with the caller. The wallet knows which of its outputs it is
willing to spend; a module that selected for it would be choosing on its behalf.

Amounts are u64 and therefore cross as BigInt, which matches the base-unit
discipline the wallet already keeps on its own side.

Signing and finalisation are not bound yet — this assembles only.
Completes the signing path in the module. finalizeTransaction takes the fee rate
and the change target rather than discovering them: the module has no network, and
change must go somewhere the wallet actually watches. Coin selection is assumed
done, which is what finalize_strict already assumed.

Returns the consensus-encoded transaction, its txid and the fee it pays, so the
caller can show the fee before broadcasting and broadcast without re-deriving
anything.

Also exposes the signer's own scriptPubKey, which is what a wallet output pays to
and therefore what a caller encodes into an input it wants signed.

This closes the largest question the initiative carried: smplx's blinding and
signing work under wasm, not just its compilation.
Binds the covenant-input side: an output locked by a Simplicity program, spent by
satisfying it, with the program's compile-time arguments and its witness values in
SimplicityHL's own shapes. Also exposes the scriptPubKey a contract's funds sit
behind, which is what an output is compared against and built from — the address
is for showing a person.

dryRunCovenantInput satisfies the witness, prunes the branches the spend does not
take, and executes the result on a BitMachine. It proves the program runs against
this transaction. It does not prove a signature nobody has made yet will satisfy
it — that is a different claim needing a run after signing.
`finalize` executes the program — satisfy, prune, BitMachine — so signing already
performs the dry-run against the witness actually produced. What it did not do was
say which input failed: the error surfaced as a bare program error, and a caller
with several covenant inputs cannot act on "something did not execute".

Adds a SignerError variant carrying the index, so a failure names the input it
happened on and keeps the underlying program error as its source.
A covenant that authenticates whoever spends it asserts a signature over the
transaction being built, and only the signer can make one. The binding declared
RequiredSignature::None for every covenant input, so the signer was never asked
and the spend failed to satisfy the program.

addCovenantInput now takes the name of that witness and passes
RequiredSignature::Witness, which is what tells the signer to sign and inject.
Passing nothing keeps the old behaviour, which is correct for the few covenants
that need no signature.
Two things stood between a correct extra-leaf encoding and a correct covenant
address, and both were in here.

The tree was balanced. The reference implementation folds left and every deployed
covenant address was derived that way, so a balanced tree produces a well-formed
address for a contract whose funds sit elsewhere. The two agree up to three
leaves and diverge from four, which is why nothing noticed until a protocol
carried three extra leaves. Asserted by a unit test on the depths.

Storage held leaves as a fixed thirty-two bytes. The format's are any length —
bytes has no length and pad_to exists precisely so a value can be shorter — and
tap_data_hash never cared, so this was a type narrower than everything around it.
A leaf of another length is a different leaf, not a padded one.

The wasm constructor takes them, since a contract with extra leaves could not be
built at all through the binding before.
Debug symbols change the CMR and therefore the covenant address, and the flag
was a process-wide setting. A wallet has to build each contract the way its own
protocol states, and one transaction can touch two protocols — which a
process-wide setting cannot express at all.

The compiler version is also exported, because a wallet that refuses a manifest
asking for another version has to be right about which one it has. It is pinned
to the workspace dependency by a test that reads the manifest rather than by
anyone remembering.
A sequence is a relative timelock: it says how long after the output it spends
was confirmed this transaction may enter a block. A covenant can require one
rather than merely permit it, and the chain rejects a transaction built without
it — so an input that could not carry one meant a declaration dropped silently
and a failure on broadcast, far from anything that explains it.

The SDK already had it. Only the binding could not say it.
The compiler search assumed llvm-ar sits beside the clang it found, which is true
of a Homebrew install and usually false of a distribution one — there it is
versioned, llvm-ar-18 and the like, with no unversioned name. PATH is tried
first, then a versioned match beside the compiler, and failing both it says so
rather than letting cargo fail somewhere that names secp256k1-sys instead.
}

#[cfg(test)]
mod depth_tests {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please move these tests to the rest of the tests, and I suspect that test_taproot_leaf_depths_known_values is going to fail. Just remove it?

/// Panics if the mnemonic fails to parse, or if deriving the master private key fails.
#[cfg(feature = "provider")]
#[must_use]
pub fn new(mnemonic: &str, provider: Box<dyn ProviderTrait>) -> Self {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's put this function first in the impl, then from_mnemonic.


/// Returns the configured provider, or an error when the signer was built without one.
#[cfg(feature = "provider")]
fn provider(&self) -> Result<&dyn ProviderTrait, SignerError> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is an outlier. There is get_provider that currently unwraps. Should we remove it and rename this one to get_provider? Please place it in the right place inside the impl block.

tx: &FinalTransaction,
target_blocks: u32,
fee_rate: f32,
change: Option<&ChangeTarget>,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would rather do it in a different way: make the change a part of the FinalTransaction and let it be configurable as a special optional variable.

}
}

/// Sets the derivation path, relative to the account path, of the key that spends this input.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please provide an example of a correct derivation path expected by this function.

pub blinding_key: Option<PublicKey>,
}

impl ChangeTarget {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd create a separate file for this struct. And then make it configurable via FinalTransaction.

Suggested change
impl ChangeTarget {
impl ChangeOutput {

Comment thread crates/sdk/src/lib.rs
Comment on lines +7 to +32
/// The SimplicityHL compiler this SDK compiles contracts with.
///
/// A wallet that refuses a manifest asking for another version has to be right about which
/// one it has, so this is pinned to the workspace's dependency by a test that reads the
/// manifest rather than by anyone remembering to update it.
pub const COMPILER_VERSION: &str = "0.6.0";

#[cfg(test)]
mod compiler_version_tests {
use super::COMPILER_VERSION;

/// Fails when the dependency moves and this constant does not, which is the only way the
/// constant can lie — and a lying constant refuses a manifest that should have built.
#[test]
fn matches_the_workspace_dependency() {
let workspace = include_str!("../../../Cargo.toml");
let declared = workspace
.lines()
.find_map(|line| line.strip_prefix("simplicityhl = { version = \""))
.and_then(|rest| rest.split('"').next())
.expect("the workspace should pin simplicityhl");

assert_eq!(declared, COMPILER_VERSION);
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't do that for now.

Comment thread crates/wasm/src/lib.rs
/// # Errors
/// Returns an error if the network name is unknown or the source fails to compile.
#[wasm_bindgen(js_name = covenantAddress)]
pub fn covenant_address(&self, network: &str) -> Result<String, JsError> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
pub fn covenant_address(&self, network: &str) -> Result<String, JsError> {
pub fn contract_address(&self, network: &str) -> Result<String, JsError> {

Comment thread crates/wasm/src/lib.rs
Comment on lines +299 to +305
fn with_sequence(input: PartialInput, sequence: Option<u32>) -> PartialInput {
match sequence {
Some(value) => input.with_sequence(Sequence(value)),
None => input,
}
}

@Arvolear Arvolear Aug 5, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is super weird here. Move to some impl block?

Comment thread crates/wasm/src/lib.rs
Comment on lines +438 to +439
#[wasm_bindgen(js_name = addCovenantInput)]
pub fn add_covenant_input(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
#[wasm_bindgen(js_name = addCovenantInput)]
pub fn add_covenant_input(
#[wasm_bindgen(js_name = addCovenantInput)]
pub fn add_contract_input(

Comment thread crates/wasm/src/lib.rs
/// Returns an error if the input is not a covenant input, or if the program fails to
/// satisfy, prune or execute.
#[wasm_bindgen(js_name = dryRunCovenantInput)]
pub fn dry_run_covenant_input(&self, input_index: usize, network: &str) -> Result<(), JsError> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
pub fn dry_run_covenant_input(&self, input_index: usize, network: &str) -> Result<(), JsError> {
pub fn dry_run_contract_input(&self, input_index: usize, network: &str) -> Result<(), JsError> {

Comment thread crates/wasm/src/lib.rs
#[wasm_bindgen(js_name = compilerVersion)]
#[must_use]
pub fn compiler_version() -> String {
smplx_sdk::COMPILER_VERSION.to_string()

@Arvolear Arvolear Aug 5, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we extract it from the cargo.toml dynamically here?

Comment thread crates/wasm/Cargo.toml
crate-type = ["cdylib", "rlib"]

[dependencies]
smplx-sdk = { path = "../sdk", version = "0.0.9", default-features = false }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
smplx-sdk = { path = "../sdk", version = "0.0.9", default-features = false }
smplx-sdk = { workspace = true , default-features = false }

@Arvolear

Arvolear commented Aug 5, 2026

Copy link
Copy Markdown
Member

Please rebase on the dev branch.

Comment thread crates/wasm/src/lib.rs
witness: Box::new(FixedWitness(witness)),
},
match signature_witness {
Some(name) if !name.trim().is_empty() => RequiredSignature::Witness(name),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is RequiredSignature::WitnessWithPath that needs to be supported as well.

Comment thread crates/wasm/Cargo.toml
@@ -0,0 +1,24 @@
[package]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we publish this crate?

Comment thread crates/wasm/src/lib.rs
/// is the value that goes into a manifest parameter naming the signer.
#[wasm_bindgen(js_name = schnorrPublicKey)]
#[must_use]
pub fn schnorr_public_key(&self) -> String {

@Arvolear Arvolear Aug 5, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add the ecdsa_public_key function.

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