Browser bindings, and the SDK changes a wallet needs to use them - #117
Browser bindings, and the SDK changes a wallet needs to use them#117lukachi wants to merge 17 commits into
Conversation
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 { |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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> { |
There was a problem hiding this comment.
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>, |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
Please provide an example of a correct derivation path expected by this function.
| pub blinding_key: Option<PublicKey>, | ||
| } | ||
|
|
||
| impl ChangeTarget { |
There was a problem hiding this comment.
I'd create a separate file for this struct. And then make it configurable via FinalTransaction.
| impl ChangeTarget { | |
| impl ChangeOutput { |
| /// 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); | ||
| } | ||
| } | ||
|
|
| /// # 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> { |
There was a problem hiding this comment.
| pub fn covenant_address(&self, network: &str) -> Result<String, JsError> { | |
| pub fn contract_address(&self, network: &str) -> Result<String, JsError> { |
| fn with_sequence(input: PartialInput, sequence: Option<u32>) -> PartialInput { | ||
| match sequence { | ||
| Some(value) => input.with_sequence(Sequence(value)), | ||
| None => input, | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
This function is super weird here. Move to some impl block?
| #[wasm_bindgen(js_name = addCovenantInput)] | ||
| pub fn add_covenant_input( |
There was a problem hiding this comment.
| #[wasm_bindgen(js_name = addCovenantInput)] | |
| pub fn add_covenant_input( | |
| #[wasm_bindgen(js_name = addCovenantInput)] | |
| pub fn add_contract_input( |
| /// 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> { |
There was a problem hiding this comment.
| 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> { |
| #[wasm_bindgen(js_name = compilerVersion)] | ||
| #[must_use] | ||
| pub fn compiler_version() -> String { | ||
| smplx_sdk::COMPILER_VERSION.to_string() |
There was a problem hiding this comment.
Can we extract it from the cargo.toml dynamically here?
| crate-type = ["cdylib", "rlib"] | ||
|
|
||
| [dependencies] | ||
| smplx-sdk = { path = "../sdk", version = "0.0.9", default-features = false } |
There was a problem hiding this comment.
| smplx-sdk = { path = "../sdk", version = "0.0.9", default-features = false } | |
| smplx-sdk = { workspace = true , default-features = false } |
|
Please rebase on the |
| witness: Box::new(FixedWitness(witness)), | ||
| }, | ||
| match signature_witness { | ||
| Some(name) if !name.trim().is_empty() => RequiredSignature::Witness(name), |
There was a problem hiding this comment.
There is RequiredSignature::WitnessWithPath that needs to be supported as well.
| @@ -0,0 +1,24 @@ | |||
| [package] | |||
| /// 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 { |
There was a problem hiding this comment.
Let's add the ecdsa_public_key function.
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/wasm—wasm-bindgenbindings built withwasm-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) andSignedTransaction.build.shdrives the build and findsllvm-arwhere distributions actually put it rather than at one hardcoded path.The signer without a provider. Networking backends are behind a
providerfeature, 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_stricttakes an optionalChangeTarget, and signing resolves a path per input instead of always0/0. Without these the signer signs only what happens to sit at one address and sends change somewhere the wallet does not watch.Nonekeeps 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_symbolswraps 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
add_covenant_inputaccepted a signature witness name that was never passed through, and the binding hardcodedRequiredSignature::None— so a covenant that authenticates its spender could not be signed at all. Found by measuring transaction weights.serde_jsonrecorded in the lockfile.Verification
cargo testacross the workspace, and the wasm package built and driven from the consuming extension's own test suite — 465 tests including the five deployedsimplicity-lendingcontracts 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.