Repository navigation
feat: add btc support for signet - #13
Merged
Merged
Conversation
`lifecycle::run` now dispatches a Bitcoin manifest: load a `BitcoinWallet`, scan Esplora for UTXOs, run the shared assembly, then build, sign and broadcast a PSBT. Elements is untouched. The two paths diverge at the build, not before. Elements carries on to separate signing, covenant dry-run and finalize steps, because a PSET passes through three stages that can each fail in a way worth reporting. Bitcoin does all three at once -- covenant execution needs the upstream jet FFI, so the only transactions reachable here are plain payments, and splitting three mechanical operations across three steps would invent places to stop. A covenant input on a Bitcoin run is refused with that explanation rather than silently skipped. `assembly` is the seam that makes one path serve both chains. The input/output assembly in `lifecycle::run` is ~800 lines of destination resolution, covenant address derivation, amount evaluation and state metadata, and it turns out to need exactly six things from the chain under it: a covenant's scriptPubKey, an asset label resolved to an id, a change address, the next receive address, the policy asset, and whether outputs are confidential by default. `AssemblyContext` is those six, with `ElementsContext` over LWK and `BitcoinContext` over `BitcoinWallet`. One assembly rather than two, because a duplicate would agree on the day it was written and drift by the next release, in ways only a funded transaction would reveal. The rewiring is a pure refactor and the existing tests are what say so. The assembly still speaks the Elements vocabulary on both chains: outputs carry an `AssetId` and an optional blinding key, and on Bitcoin the asset is a single synthetic constant and the blinding key is always `None`. That leak is deliberate. The alternative -- a neutral vocabulary both chains widen from -- means rewriting all 800 lines against it, which is the risk the seam exists to avoid. The fiction does not have to hold together on its own either, because `psbt_builder::from_pset_request` refuses any second asset or blinding key, so the narrowing enforces the invariant rather than this module being trusted to maintain it. `assembly::bitcoin_spendable_utxos` is the input-side counterpart: real outpoint, value and scriptPubKey in LWK's `WalletTxOut` shape, synthetic asset and blinding factors, which keeps input selection -- several hundred lines of amount and asset matching, sitting in the path funds move along -- off the list of things being rewritten. A test carries a UTXO through the assembly's vocabulary and back out through the narrowing to confirm the real fields survive. `BitcoinRun` carries its own `Network` rather than deriving one from `network_for_asset`, which is an `ElementsNetwork` computed from the wallet file's mainnet flag and is meaningless on Bitcoin; taking it from there would hand the covenant derivation the wrong chain. Also found here: a covenant's scriptPubKey is chain-specific in its *bytes*, not only in its address encoding. The taproot tweak is domain-separated the same way the tag hashes are (`TapTweak/elements` versus `TapTweak`), so one covenant tree yields different script bytes on the two chains. That is a third independent reason a covenant address is chain-specific, alongside the jet CMRs and the TapBranch tag, and the only one of the three with no visible symptom: an Elements-derived script is a perfectly well-formed P2TR output on Bitcoin, and a transaction paying it looks entirely normal right up until nobody can ever spend it. `covenant_script_pubkey_for` dispatches on the network and `compute_bitcoin_covenant_address` is its Bitcoin half; both share one merkle-root computation, so they cannot fold different trees while disagreeing (correctly) about the tweak. `psbt_builder::convert_script` claimed the opposite in its doc comment -- the same bytes on both chains, "a byte copy rather than a re-derivation" -- and now says what it actually is: correct only for a script already derived for the target chain.
Two bugs found by reading the cross-module seams. Both were invisible to every module-level test, because in each case the two modules were individually right and disagreed only with each other. Both would first have surfaced against a funded wallet. - select_input resolved the manifest labels "lbtc"/"bitcoin" through an ElementsNetwork, so a Bitcoin run looked for Liquid's policy asset and matched none of its own UTXOs: a funded wallet reporting itself empty, with no error. It now takes the run's Network and derives both the asset it matches on and its address parser from that one value, so the two cannot drift apart again. - Manifest addresses were parsed as elements::Address at both sites that read one, and that parser rejects every Bitcoin address. Neither site treated the failure as fatal: the output loop dropped the output and built the transaction without it, the declared payment missing and its value falling into change, while from_address degraded from "spend this coin" to "spend anything". Both are now fatal -- dropping a declared output or a declared restriction is not a recoverable condition -- and Bitcoin addresses are checked against the run's network rather than merely parsed, since a mainnet address parses fine on a signet run. bitcoin_rpc talks to a node's own JSON-RPC, selected with bitcoin_backend: "rpc". Esplora is the right backend against a public network, where someone else runs the indexer, and the wrong one against a regtest you just started, where it means an electrs and an API server to index four blocks. scantxoutset finds our coins from descriptors with no wallet, no import and no rescan. BTC amounts are converted from Core's decimal textually, never through f64: 0.1 BTC via a float lands under 10_000_000 and truncates, and one satoshi off invalidates every signature committing to it. contrib/regtest builds delta1/bitcoin@simplicity-inquisition. SIMPLICITY is active from height 0 as an enabled script flag rather than a BIP9 deployment, so deployment_active checks getdeploymentinfo's script_flags as well as its deployments -- reading only the latter reports an active rule as inactive and would refuse a covenant run that would have worked. tests/bitcoin_path.rs exercises the seam rather than the parts: scanned UTXO -> the shared assembly's vocabulary -> the narrowing -> a built, signed, extractable transaction, with the signature verified against the scriptPubKey being spent. Plain Bitcoin payments are now reachable end to end. Covenant spending still needs a SimplicityHL build carrying the Bitcoin jet hinter, which this crate does not yet pin; the node will execute Simplicity, but the wallet cannot yet compile a program for it.
Everything here came out of running a Bitcoin config through commands that had only ever seen Elements. Each one silently did the wrong thing rather than failing, because none of them dispatched on the chain. - `sync` ran the Elements path wholesale on a Bitcoin config: a ct/slip77 descriptor, a tex1q… Liquid address, and a request to the Elements Esplora setting. It now scans through the configured backend and reports the balance, as does `get-balance` -- Bitcoin keeps no wallet database, so the scan is the only source and there is no "last known" balance to read back. - `info` showed a Liquid address for a Bitcoin wallet. That is the one line a user acts on, and it named a different chain. It now reads the configured network rather than the wallet file's mainnet/testnet flag. - `prepare` and `split` reported "Wallet has no UTXOs … run `sync` first" against a wallet holding a hundred of them, because they read the LWK database a Bitcoin run never populates. They now refuse explicitly and say why, rather than sending the reader off to fix their funding. - `Config::is_mainnet` compared the network string to "mainnet", so `default_network: "bitcoin"` -- which *is* mainnet -- came back false, and a wallet created against a Bitcoin mainnet config was recorded as a testnet one. Both Config and WalletFile now parse the network; files saying mainnet/testnet still mean Liquid and Liquid testnet, pinned by tests. - `default_network` could not be set to any Bitcoin network at all: the CLI setter checked a two-item list predating Bitcoin support, so regtest could only be configured by editing the file by hand. - Regtest's Esplora default pointed at a public signet, and did exactly that during a run before it was caught. It now points at localhost, which fails to connect -- the right failure. Quietly answering questions about somebody else's chain is the worst outcome available. - The fee estimator was never dispatched either, so a Bitcoin run asked an LWK wallet about a UTXO it had never heard of and the `fee` keyword could not resolve. Every output amount depending on it is then wrong by exactly the fee. Coinbase maturity is now modelled: `scantxoutset` reports `coinbase` and `confirmations`, immature outputs are excluded from selection, and the run says what it skipped. On regtest, where mining is how a wallet gets funded, selecting an immature reward was the default outcome rather than an edge case. The filter sits at selection rather than at the scan, because a freshly mined coin is genuinely the wallet's and a balance omitting it would be wrong. --config points at a config file, and TX_MANIFEST_DATA_DIR relocates the whole data directory. The config previously lived at a fixed global path that `config::load` resolved with no argument, so pointing the wallet at a regtest meant overwriting the config a user's real funds are reached through -- which made the thing untestable by anyone who also used it. A missing --config path is refused rather than falling back to the default, since a typo would otherwise point at a real, possibly mainnet, config. examples/bitcoin_pay is the smallest manifest that runs on Bitcoin: `Pay`, and `Sweep`, which exercises the `fee` keyword. Both verified against the regtest node at exactly the requested fee rate.
Pins delta1's Bitcoin-enabled SimplicityHL fork and wires the covenant path
through the lifecycle, so a manifest with `"chain": "bitcoin"` and a covenant
utxo_type locks and unlocks coins end to end. Verified against the regtest in
contrib/regtest: both transactions confirmed, the unlock witness four items --
64 bytes of Simplicity witness, a 53-byte program, the 32-byte CMR, a 33-byte
control block -- and the node executing the program rather than trusting us.
The pin is the risky part, because that fork also serves the Elements path. It
does not move anything: p2pk.simf still derives
tex1pese0jw8t92vwf4ma0e0pdgjgg3mn436kmcaxyms3vrqy205aglqssn9s5t byte for byte,
every recon example still reconstructs its live Liquid address, and the Elements
CMR is now pinned in a test so a later pin change cannot move it quietly.
A covenant address turns out to diverge per chain in FOUR independent ways, not
the three previously found. A jet's CMR depends on its position in its jet set,
and all three taproot tags are domain-separated. TapLeaf was still hashed under
`TapLeaf/elements` for both chains, and that one hid longest for a specific
reason: an address and its own control block stay consistent with each other, so
funding works perfectly and only the spend fails -- with `Witness program hash
mismatch`, which points at the witness rather than at the tag that built it. Had
it shipped, the symptom would have been "my covenant received funds and now
nothing can spend them". Each chain's leaf hash is now checked against that
chain's own implementation, so a typo cannot pass by agreeing with itself.
finalize_bitcoin_covenant_input mirrors the Elements finalizer and differs in
three places, each a consequence of the chain: BitcoinEnv takes no genesis hash,
since Bitcoin's sighash does not commit to one; SimplicityHL's `satisfy_with_env`
is typed to ElementsEnv so the Bitcoin path satisfies unpruned; and Bitcoin's
validator wants the program's cost inside a (minCost, budget] window, so a cheap
program is padded up into it with a plain all-zero stack item -- not
`Cost::get_padding_bytes`, whose Elements form is an annex whose leading 0x50
would be misread.
Also fixed, all found by running it:
- The signing key comparison was exact, so `0xc01e26…` from a manifest and
`c01e26…` from a derived key -- the same key, written the way each side
naturally writes it -- compared unequal, and the signer reported it as
belonging to another wallet.
- A Bitcoin run wrote no state file, because run_bitcoin_build returned before
reaching the Elements post-broadcast step that writes one. The covenant an
action had just created was therefore invisible to the next action, and the
outpoint had to be named by hand. The meta-to-vout matcher is now shared
between the chains rather than copied: it consumes each vout at most once,
because several outputs can share a scriptPubKey, and two copies of that rule
would drift.
- State selection compared asset labels as written, so a manifest saying "lbtc"
did not match a state file recording the resolved id -- and the covenant this
engine had recorded a moment earlier looked absent. Compared as resolved ids
now, and BitcoinContext::resolve_asset accepts the synthetic id as well as the
label, since that is the spelling the state file feeds back.
contrib/regtest gains faucet.sh and mine.sh, sharing one definition of the
container and the burn address. The faucet mines the coins asked for to the
wallet and the 100 maturity blocks to an unspendable address, so a wallet ends up
with exactly the UTXOs requested and nothing immature; mining 201 blocks to your
own address leaves a hundred outputs every later scan has to walk.
Still missing: environment pruning, so a branchy program carries dead branches
into its witness; and roughly 27 of 428 jets have their FFI wired in
rust-simplicity, which is why p2pk works and richer covenants do not yet.
prune bitcoin covenant programs, and correct two claims
A branchy covenant could not be spent. Simplicity's anti-DoS rule requires a
redeem program to contain no unexecuted nodes, so an unpruned program carrying a
`match` is rejected outright:
mempool-script-verify-flag-failed (Anti-DOS check failed)
which reads like a resource limit and actually means "this program has branches
you did not take". SimplicityHL's `satisfy_with_env` prunes on the Elements side
but is typed to ElementsEnv, so the Bitcoin path had been satisfying unpruned.
`RedeemNode::prune` underneath it is generic over the environment, so the fix is
to call it directly with the BitcoinEnv already in hand.
Confirmed against the regtest on a three-path covenant -- nested match, relative
timelock, output introspection -- ported from examples/last_will. Unpruned it was
rejected; pruned it spends, and the witness drops from 483 to 276 bytes as the
two untaken branches fall away.
Two things the parent commit states that are not true, recorded here because that
commit is already pushed and should not be rewritten:
- "roughly 27 of 428 jets have their FFI wired". That was true of rust-simplicity
revision 23dc70f, probed a fortnight ago, which carried a hand-curated
whitelist with an `unimplemented!` fallback. The pin resolved to 13c1bce, where
every one of the 428 has its C implementation wired and no fallback remains.
The belief that only p2pk-shaped covenants could run came from the stale
reading.
- "environment pruning is missing, so a branchy program carries dead branches".
Missing from SimplicityHL's wrapper, but available underneath it, as above.
What actually limits a Bitcoin covenant now is the cost budget: a program's cost
must land inside the validator's (minCost, budget] window, where the budget comes
from the witness size. Padding raises a cheap program into it; an expensive one
has no such escape and simply has to be cheaper.
signet.simplicity-lang.org runs a custom signet with Simplicity active since
height 1296, and serves an Esplora API, so a covenant can now be locked and
unlocked on a public network with no node of our own. Getting there turned up
one way to target the wrong chain silently, one way to mix two chains in one
run, and one input that resolved to zero.
The wrong chain. Every signet shares a genesis block, reports itself as
`signet`, and encodes addresses as `tb1…`, so a wallet pointed at the wrong one
scans, derives and builds without complaint. The bitcoin-signet Esplora default
is Blockstream's -- the default signet, which has not activated Simplicity.
Two guards, both in `Config::bitcoin_chain`, which every Bitcoin run and `sync`
connect through:
- `bitcoin_checkpoint: {height, hash}` pins a block the chain must contain,
checked on connect against either backend. Block 1296 differs between the two
signets (00000091… here, 000002e2… on the default), which is all it needs. A
chain short of the height is refused too: a different chain or one still
syncing, and nothing it says about our coins can be trusted yet.
- `simplicity_activated` on bitcoin-signet with no `default_esplora` is refused
outright. Opting in means a specific signet, so leaving the URL to the default
can only mean the line naming it was forgotten -- and a config that forgot that
has likely forgotten a checkpoint as well.
Two chains in one run. `info` printed a Liquid testnet address under a
`bitcoin-signet` label: the wallet came from the current directory, the config
from a fixed global path, and nothing checked that they agreed. Now, before
anything reads the config, every command taking `--wallet` binds to it:
- without `--config`, a `config.json` beside an existing wallet file is used,
and announced on stderr; `create-wallet --out dir/w.json` picks up
`dir/config.json`, so a wallet is made for the chain its config names
- a config, or `--network`, naming a different network from the wallet's is an
error naming both -- its URLs, checkpoint and activation were written for that
other chain
- the wallet's network then replaces `default_network` for the rest of the run.
The wallet is the one thing that cannot be wrong about its chain; a config is
settings. `default_network` may now be omitted, leaving a file of backend
settings for whichever wallet it sits beside.
The startup check parses the config strictly, so a malformed file is reported
rather than replaced by the defaults `config::load` substitutes -- a bad
`bitcoin_checkpoint` had been quietly turning a signet wallet into a Liquid
testnet one.
The zero input. `--input vault_in=txid:vout` read the outpoint's amount through
`fetch_onchain_txout`, which only knows Elements: it asked the Liquid testnet
Esplora about a Bitcoin txid, every fallback was empty, and the input resolved
to 0 sat -- surfacing two steps later as `vault_in.amount_sat - fee` going
negative. A Bitcoin run now reads it through its own backend: the explorer's
`tx/{txid}/hex` on Esplora, `gettxout` on a node (no -txindex needed, and a
spent outpoint comes back empty rather than spendable).
Verified on the signet: `sync` passes the checkpoint and scans to the tip; the
same config pointed at Blockstream's signet is refused at block 1296; Unlock of
57a6f82a…:0 reads 1,000,000 sat from the explorer, satisfies the covenant and
signs (exported, not broadcast).
examples/bitcoin_covenant: config.json is now the signet config (also as
config.signet.json), the regtest one moves to config.regtest.json, and Unlock
pays back to the wallet rather than to a `dest` param.
A config names both a file to read as the RPC cookie and a URL to send it to, so unchecked it could send any local file anywhere: `bitcoin_rpc_cookie: wallet.json` with a remote `bitcoin_rpc_url` shipped the mnemonic out as a Basic auth header on the first call. With configs now picked up beside the wallet, a repo carrying a config.json was enough. - the cookie file must look like Core's (`__cookie__:<hex>`, at most 256 bytes), and a refusal never echoes what the file contained - a cookie is only sent to loopback: it authenticates a node on this machine, so no remote host is its rightful recipient, https or not - user:password from the config goes to loopback, or anywhere over https; plain http to a remote host is refused, pointing at an SSH tunnel instead Loopback means a loopback address or the name `localhost`; any other name could resolve anywhere. `url` is now a direct dependency (already in the lock via ureq). Fixes S2 from the PR #12 review.
`simplicity_activated` was taken on trust, and its docs said a wrong value costs a rejected broadcast, not a coin. It costs the coin. On a chain without the soft fork, leaf version 0xbe is an unknown leaf version, which BIP341 makes valid unconditionally: the funding transaction is an ordinary P2TR payment every node accepts, and the covenant output it creates is spendable by any miner. A covenant run (`requires: ["simplicity"]`) now checks the chain itself as soon as the backend connects, before the scan and before any covenant address exists: - Bitcoin mainnet is refused outright -- nothing has activated there - a node is asked, through the `deployment_active` that existed but was never called - Esplora cannot report a deployment, so a `bitcoin_checkpoint` is required, tying the claim to one pinned chain rather than whatever the URL reaches The config flag stays as the first gate; it is now necessary, not sufficient. Verified against the Simplicity signet's Esplora: the example config (with its checkpoint) passes, the same config without it is refused, and a mainnet wallet with `simplicity_activated: true` is refused before any request is made. The RPC branch is not exercised live here -- no node was running. Fixes N1 from the PR #12 review.
Which network a run targets, and the config that reaches it, was decided in the CLI
behind two process-global OnceLocks that changed what a no-argument config::load()
returned. The wallet/config agreement checks therefore held only for code reached
through main: a library caller of lifecycle::run got none of them, its network
came from `network.unwrap_or(cfg.default_network)` rather than from the wallet,
config::load() still turned a malformed file into the defaults, and one process
could not serve two wallets.
- target::Target { network, network_label, config, config_path }, made by
Target::bind(loaded_config, wallet, network_flag), which holds the rules the
CLI had: the wallet decides; a config or --network naming another network is an
error; without a wallet, the flag, then the config, then the legacy default
- `network_label` keeps the spelling given, so `<stem>.testnet.json` params files
still load rather than being looked for as `.liquid-testnet.json`
- lifecycle::run and run_headless take &Target; the four config::load() calls in
lifecycle are gone, and run re-checks that the wallet it loads is the one the
target was bound to, since a library caller binds and runs in separate steps
- config.rs: load_from(path) is strict, and reports `declared_network` apart from
the legacy default; save_to(path); default_path(). CONFIG_PATH_OVERRIDE,
WALLET_NETWORK, load(), save() and declared_network() are removed
- the CLI picks the file (--config, else beside the wallet, else the default),
binds once, and passes the Target to each command
Breaking for library callers: run and run_headless take &Target in place of a
network string. Fixes A3 from the PR #12 review.
lifecycle::run carried an Option<BitcoinRun> and checked it separately wherever
the chains differ: the run's network, the wallet's UTXOs, an outpoint's amount,
the assembly context, the fee estimator, the build. Every chain-specific bug the
Bitcoin work turned up lived in one of those checks -- a covenant input resolving
to 0 sat because the amount lookup asked the Elements backend, `fee` failing to
resolve because the estimator asked an LWK wallet about a Bitcoin UTXO.
session::ChainSession is chosen once, at the top of the run, from the Target and
the wallet: Elements { network } or Bitcoin(BitcoinRun). Each of those questions is
now a method on it -- network(), spendable_utxos(), txout(), assembly_context(),
estimate_fee(), bitcoin() -- so a third chain is a new variant rather than another
check at every site. BitcoinRun and both txout fetchers move with it, and
run_bitcoin_build takes &BitcoinRun instead of an Option it had to unwrap.
Behaviour is unchanged. The Elements session keeps taking its network from the
wallet file, as before: it decides the LWK network and the policy asset, and
switching it to the target's would change which asset an Elements regtest wallet
resolves. Verified on the Simplicity signet: Lock and Unlock, exported.
Fixes the substance of A2 from the PR #12 review; splitting run into modules is
left for a separate, purely mechanical change.
After a transaction is on the network a run records it: the action's on_post_broadcast hook, the state file (spent covenant inputs out, created covenant outputs in) and its history. That was two copies, one per chain, and they had drifted: the Bitcoin copy never ran the hook, and appended history even when the state file could not be written. Both chains now go through one record_broadcast(&BroadcastRecord, txid, outputs, ...). Failures that mean nothing was sent now return Err, so the exit status says so: - Bitcoin: a covenant input that cannot be resolved, or any build, signing or broadcast failure from run_bitcoin_build -- these printed and returned Ok - Elements: a broadcast failure, and a PSET that cannot be built with a wallet loaded (which used to carry on to the broadcast prompt with nothing to send) Declining at the prompt and --export-pset still exit 0. A state-file write that fails after a broadcast is still a warning, not an error: the transaction is out, and an error would read as nothing having happened. Verified on the Simplicity signet: declining exits 0 with "Not broadcast."; broadcasting a spent covenant input exits 1 with the node's own bad-txns-inputs-missingorspent. Tests cover the hook, state and history recording, and that no history is written when the state cannot be. Also drops a doc block on CovenantSpendSpec that still said covenant spends were unsupported on Bitcoin, and takes clippy's contains() in the capability gate. Fixes A1 and B4 from the PR #12 review.
stringhandler
marked this pull request as ready for review
October 5, 2026 08:08
stringhandler
force-pushed
the
st-add-btc-support-reviewed
branch
from
October 5, 2026 08:57
5b5951f to
c156b69
Compare
main moved five commits since this branch was cut, chiefly #11, which ran rustfmt across the crate. Ten files conflicted, lifecycle.rs with 22 hunks, but after rustfmt is applied to the merge base, main's semantic changes to the conflicting files come to two: - lifecycle.rs: amount_uses_fee_keyword becomes pub(crate), for validate - validate.rs: the "nowhere for an L-BTC surplus to go" check, its helper lbtc_surplus_has_a_home, and its tests Resolution: this branch's side of each conflicted file, those two changes ported onto it, then rustfmt on the resolved files so main's formatting is kept rather than undone. The ported tests' fixture now says manifest_version 0.3.0, which this branch requires. CHANGELOG keeps both: [Unreleased] from this branch, then main's [0.2.1]. lib.rs keeps this branch's module list (rustfmt not run on it, since that would recurse into every module). Verified: cargo test --workspace --locked passes (272 lib tests: this branch's 260 plus main's 12); the signet covenant Unlock still builds, satisfies and signs. Not fixed here: main's new surplus check fails examples/deadcat and examples/deadcat_v2, on main alone as well -- no test validates the examples.
stringhandler
force-pushed
the
st-add-btc-support-reviewed
branch
from
October 5, 2026 09:04
c156b69 to
391de67
Compare
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.
No description provided.