Skip to content

Build and sign covenant spends from JavaScript - #128

Open
lukachi wants to merge 2 commits into
BlockstreamResearch:devfrom
lukachi:humid/wasm-issuance
Open

Build and sign covenant spends from JavaScript#128
lukachi wants to merge 2 commits into
BlockstreamResearch:devfrom
lukachi:humid/wasm-issuance

Conversation

@lukachi

@lukachi lukachi commented Aug 20, 2026

Copy link
Copy Markdown

A wallet driving this SDK from JavaScript could not express several things a covenant spend needs, and one of them was silently wrong. Each change below was found while performing a deployed SimplicityHL lending protocol end to end on Liquid testnet from a browser extension: seven distinct actions, all confirmed on chain.

Issuance on both input shapes

addWalletIssuanceInput and addContractIssuanceInput are exposed to JavaScript, so a transaction that creates an asset can be assembled from the browser. Both report the asset id, entropy and reissuance token they derived, which lets the caller compare them against its own derivation instead of trusting one side.

Parameter types across the boundary

contractParameterTypes(source) answers what a contract's parameters are declared as, from the source alone and before there are any arguments to build with. SimplicityHL declares no type at a parameter site — the type checker works it out from the position the parameter is used at — so the compiler is the only thing that can say, and a caller was previously left guessing at the encoding.

A covenant is rebuilt from the parts it was committed to

Contract now takes the extra taproot leaves and the build mode alongside the source and arguments. All four decide the script a covenant locks to, so building with only the first two derives an address for a different contract — one whose spend the covenant refuses at execution, after signing.

A transaction can declare its lock height

FinalTransaction::set_locktime_height (setLocktimeHeight in wasm) writes the height onto every input that does not already carry one. A covenant branch guarded by check_lock_height reads the transaction's own locktime, and one that declares none satisfies no such branch.

The bug this fixes is in PartialInput::to_input. It wrote Some(Height(0)) into required_height_locktime whenever an input carried no lock height of its own. PSET reads a present-but-zero height as a constraint rather than as its absence, so locktime() returned zero for the whole transaction and every timelocked spend failed. A zero height is now treated as no height:

let height_locktime = match self.locktime {
    LockTime::Blocks(value) if value.to_consensus_u32() > 0 => Some(value),
    LockTime::Blocks(_) | LockTime::Seconds(_) => None,
};

A refusal that can be diagnosed

SignerError::CovenantExecution carries the transaction's locktime and the input's sequence in its message. Both decide whether a timelocked branch runs, and without them a refusal is indistinguishable from any other execution failure.

Checks

cargo check --workspace --all-targets and cargo test -p smplx-sdk pass on this branch, rebased onto dev at c9cae5d. The nineteen SDK tests include two new ones pinning a declared transaction locktime and its absence through extract_pst.

@lukachi
lukachi requested a review from Arvolear as a code owner August 20, 2026 12:13
A wallet driving this SDK from JavaScript could not express several things a
covenant spend needs, and one of them was silently wrong.

- Issuance is exposed on both input shapes, so a transaction that creates an
  asset can be assembled from JavaScript.
- A contract's parameters carry their declared types across the boundary,
  instead of arriving as untyped values the caller had to guess at.
- A covenant being spent is rebuilt from the same parts it was committed to,
  so the leaf that is revealed matches the one in the tree.
- A transaction can declare the height it may not be mined before, which a
  timelocked covenant requires. A zero height is treated as no height, because
  writing zero into an input reads back as a constraint that can never be met
  and refuses every spend.
- A covenant that refuses now says what the transaction declared, naming the
  locktime and the sequence, so a refusal can be diagnosed without a debugger.
@lukachi
lukachi force-pushed the humid/wasm-issuance branch from 783ef77 to ee09113 Compare August 20, 2026 12:32
@Arvolear
Arvolear force-pushed the humid/wasm-issuance branch from 70a0682 to be24fed Compare August 21, 2026 12:17
Comment thread crates/wasm/src/lib.rs
}

/// An id as it is written turned into the bytes it is made of, which run the other way.
fn read_id(written: &str) -> Result<[u8; 32], String> {

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 and the next functions are super weird. What do they do? Do they have to be in the root of the file?

Comment thread crates/wasm/src/lib.rs
/// rather than assumed, because the wallet derives the same asset for itself and two
/// derivations that agree only because neither was told anything have not agreed about
/// anything.
fn issuer_contract(written: Option<&str>) -> Result<[u8; 32], String> {

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 has nothing to do with the contract. This is just a 32-byte randomness.

Comment thread crates/wasm/src/lib.rs
}

/// The module's account of one issuance, in the form ids are written in.
fn issuance_report(details: &IssuanceDetails) -> IssuanceReport {

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.

Is this IssuanceReport constructor?

Comment thread crates/wasm/src/lib.rs
/// # Errors
/// Returns an error if the source does not parse or does not type-check.
#[wasm_bindgen(js_name = contractParameterTypes)]
pub fn contract_parameter_types(source: &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.

Should this be in the root?

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