Skip to content

bitcoin jets: implement Bitcoin jet cost and CMR tables; split cost padding API - #405

Open
delta1 wants to merge 4 commits into
BlockstreamResearch:masterfrom
delta1:pr/bitcoin-jets-cost-padding-cjetptr
Open

delta1 wants to merge 4 commits into
BlockstreamResearch:masterfrom
delta1:pr/bitcoin-jets-cost-padding-cjetptr

Conversation

@delta1

@delta1 delta1 commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

Includes and supercedes #386 #387 #388

Implements the rest of the Bitcoin jet environment from #331: the cost() and cmr() stubs for Bitcoin jets are replaced with full tables derived from the canonical primitiveJetNode.inc in libsimplicity, covering all 428 jets in the Bitcoin jet set.

Splits Cost::get_padding into a chain-agnostic get_padding_size plus a chain-specific get_padding_bytes. While Elements uses an annex item ([0x50] + zeros), Bitcoin must pad with a plain all-zero stack item.

Also declares the FFI bindings for the env-context Bitcoin jets (with tests exercising them against a real transaction environment) and drops the now-unnecessary Arcs in environments.

Closes the remaining gaps in RedeemNode::prune(): the Rust Bit Machine can now execute Bitcoin jets against a real CTxEnv in order to rewrite dead case branches into asserts.

Draft until I figure out how to resolve this with upstream code generation for simplicity-sys/src/c_jets/c_env/bitcoin.rs

Update the vendored libsimplicity to 4e0beb5947b7e75ee7974d82a1447e89a0b4b5d6
and regenerate the jet files with update_jets.sh.

At this revision GenRustJets emits the Bitcoin jet CMRs and costs that were
previously replaced by an unimplemented!() stub, so src/jet/init/bitcoin.rs
now carries the real CMR and cost tables for all 428 Bitcoin jets, and
update_jets.lock records their hashes rather than a hand-written value.

The vendored C tree is unchanged: the only difference between the previous
revision and this one is the generator guard that suppressed Bitcoin output.
Add src/jet/bitcoin/spec_tests.rs, asserting the CMR and cost of every
Bitcoin jet against the values in libsimplicity's primitiveJetNode.inc.

Both tables are generated from that file by update_jets.sh, so these tests
detect drift between the vendored C data and the generated Rust: if the
vendored revision changes and the regenerated tables move, the tests fail
rather than silently changing consensus behaviour.
Add Cost::get_padding_size, which returns the chain-agnostic byte length
of the witness stack item required to bring a program cost within budget.
The required length depends only on the item's serialized size, not its
content, and is the same for Bitcoin and Elements.

Add Cost::get_padding_bytes, which returns the chain-specific bytes that
fill that length:
- Bitcoin (cfg bitcoin, not elements): a plain all-zero item. A leading
  0x50 byte would be misread as an annex, so Bitcoin must not use one.
- Elements (cfg elements): an annex item: [0x50] followed by zeros.

Deprecate Cost::get_padding (Elements annex form), which now delegates
to get_padding_bytes. Make get_budget and is_budget_valid chain-agnostic
by computing the witness-stack serialized length with explicit CompactSize
math instead of the elements consensus encoder.

The padding arithmetic itself is unchanged; only the surrounding API is
split. Extend the Bitcoin test with the CompactSize boundary deficits
(254, 255, 65 539, 65 540), where the length prefix grows and the smallest
covering item is one byte larger than the deficit would suggest.
Declare the FFI bindings for the Bitcoin jets whose C implementations
read the transaction environment (sig_all_hash, check_lock_*,
current_index, num_inputs, tx_hash, tap_env_hash, annex_hash, the
tapbranch/tapleaf/taptweak builders, and the remaining tx-context jets
in bitcoinJets.c). These match the rustsimplicity_0_8_bitcoin_* symbols
already generated in jets_ffi for the elements backend.

c_jet_ptr itself is unchanged: the Bitcoin backend routes every jet,
including these, through the existing jets_wrapper wrappers, which
forward the Rust CTxEnv reference to the same C symbols. The new
declarations are not separately bound in the generated jet table.

Add src/jet/bitcoin/tests.rs, which exercises the environment path
end to end against a real transaction:

- c_jet_env_is_the_tx_env checks that the JetEnvironment hands out the
  same CTxEnv the backend was built with, rather than a unit value.
- env_jets_execute_with_real_environment runs an env-context jet through
  the Rust Bit Machine and checks it observes the live environment.
- bip_0340_verify_ptr_resolves covers the one env-free crypto jet, which
  resolves through the shared wrapper.
- check_lock_height_executes_against_env executes a jet that reads the
  environment and asserts on its result.

These guard the property that RedeemNode::prune() depends on: the Rust
Bit Machine must be able to execute Bitcoin jets against a real
transaction environment in order to rewrite dead case branches into
asserts.
@delta1
delta1 force-pushed the pr/bitcoin-jets-cost-padding-cjetptr branch from a717ac1 to b4cc29f Compare November 10, 2026 10:19
@delta1
delta1 marked this pull request as ready for review November 10, 2026 10:25
@delta1

delta1 commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

Rebased to revendor libsimplicity

@stringhandler stringhandler left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

utACK b4cc29f

Comment thread src/analysis.rs
/// identically: a CompactSize item count, followed by each item as a
/// CompactSize length prefix and its bytes. The math is therefore
/// chain-agnostic, and this method is available under the `bitcoin` feature
/// (which `elements` implies) so it can be shared by both.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In c0bfd57

The elements feature does currently imply bitcoin, but that might not always be the case, so I think it would be better to add elements here as well.

This is a nit though, as it will show up in compiler problems if the dependency is removed.

Comment thread CHANGELOG.md
stack item needed to bring a cost within the witness budget, and `Cost::get_padding_bytes`,
returning the chain-specific bytes that fill it (an all-zero item on Bitcoin, an annex on
Elements). Deprecate `Cost::get_padding` in favour of these.
* `Cost::get_budget` and `Cost::is_budget_valid` now take `&[Vec<u8>]` instead of `&Vec<Vec<u8>>`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LLMs tend to add random information to the CHANGELOG. This is a private method so I'd vote to remove it. Fine to do in follow up.

@stringhandler

Copy link
Copy Markdown
Contributor

ACK b4cc29f; Ran through local tests and fuzzing

@KyrylR KyrylR left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Note

🤖 Claude Opus 5.5 responding on behalf of @KyrylR

Superseded by the inline review: #405 (review)

@KyrylR

KyrylR commented Nov 10, 2026

Copy link
Copy Markdown
Collaborator

Ran some of my AIs on the PR

This is a pilot test, so if you think that this "batch" is bad and distributing or you would like to see it in a different way, pls let me know for the future

@KyrylR KyrylR left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Note

🤖 Claude Opus 5.5 responding on behalf of @KyrylR

@KyrylR authorized posting this review. It was written by AI reviewers: ten independent reviews of b4cc29f, each in its own worktree. Each inline comment ends with an "Agreed by" line naming the AI reviewers that reported it independently. Opus 5.5 = Claude Opus 5.5 (×2), Mythos = Claude Mythos 5.2, Fable = Claude Fable 5.2, Astra = GPT-6 Astra (×3), Sol = GPT-6.1 Sol (×3).

The generated Bitcoin CMR and cost tables are correct; the padding API and the hand-written FFI block need changes, as described in the 9 inline comments.

Checked and correct:

  • All 428 CMRs and costs match the vendored bitcoin/primitiveJetNode.inc, with 0 mismatches across independent parsers.
  • 3f0bb69..4e0beb5 changes only GenRustJets.hs, and update_jets.sh --check passes.
  • Elements budget and padding results are identical to the old encoder-based code.
  • Tests pass for every feature combination and on MSRV 1.74; clippy -D warnings, fmt and docs are clean.

The tables commit (ffd1c22) could land on its own.

Comment thread src/analysis.rs
/// This is the Bitcoin-specific form: the padding is a single all-zero
/// witness stack item. (Bitcoin has no annex, so the padding must be a
/// regular stack item; a leading `0x50` byte would be misread as an annex.)
#[cfg(all(feature = "bitcoin", not(feature = "elements")))]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Note

🤖 Claude Opus 5.5 responding on behalf of @KyrylR

In c0bfd57

Major: get_padding_bytes returns different bytes depending on which Cargo features are enabled.

Cost::from_milliweight(56_000).get_padding_bytes(&[]) returns [00, 00, 00, 00] with --no-default-features --features bitcoin, but [50, 00, 00, 00] with default features.

elements is a default feature, and any crate in the dependency graph can enable it, so a Bitcoin user can silently get the annex form. A build with both features cannot get the Bitcoin form at all.

Suggest two explicitly named methods, or a chain parameter, rather than selecting behaviour with cfg(not(feature = "elements")).

Agreed by: Opus 5.5 ×2, Mythos, Fable, Astra ×3, Sol ×1

Comment thread src/analysis.rs
Comment on lines +216 to +217
/// witness stack item. (Bitcoin has no annex, so the padding must be a
/// regular stack item; a leading `0x50` byte would be misread as an annex.)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Note

🤖 Claude Opus 5.5 responding on behalf of @KyrylR

In c0bfd57

Major: the all-zero padding item produces invalid witnesses, and the premise in this doc comment is wrong.

  • Appending it breaks the witness. test_get_padding_bitcoin appends the padding item, which puts it where Taproot expects the control block. With witness [0x51 script, valid 33-byte control block] and cost 90,000 mWU, this returns [0, 0] and is_budget_valid then reports true, but ControlBlock::decode on the last item fails with InvalidControlBlockSize(2).
  • "Bitcoin has no annex" is incorrect. BIP341 defines the annex on Bitcoin, and this crate extracts it in src/jet/bitcoin/c_env.rs and has AnnexHash / CurrentAnnexHash jets.
  • The Elements advice (L232-L235) gives an invalid spend. Elements interpreter.cpp requires stack.size() == 2 after removing annex, control block and script, and otherwise fails with SCRIPT_ERR_SIMPLICITY_WRONG_LENGTH.

Suggest using annex padding on both chains, or dropping the Bitcoin variant until Bitcoin Simplicity defines a witness layout. Tests should parse the full witness rather than only check the budget.

Agreed by: Opus 5.5 ×2, Mythos, Fable, Astra ×3, Sol ×3 (Elements stack.size() == 2: Mythos, Fable)

Comment on lines +113 to +144
// Bitcoin jets whose C implementations read the transaction environment.
// Each has signature `bool f(frameItem* dst, frameItem src, const txEnv* env)`
// (note `src` is passed *by value*, matching the C definitions in bitcoinJets.c).
#[link_name = "rustsimplicity_0_8_bitcoin_sig_all_hash"]
pub fn bitcoin_sig_all_hash(dst: *mut CFrameItem, src: CFrameItem, env: *const CTxEnv) -> bool;
#[link_name = "rustsimplicity_0_8_bitcoin_check_lock_height"]
pub fn bitcoin_check_lock_height(
dst: *mut CFrameItem,
src: CFrameItem,
env: *const CTxEnv,
) -> bool;
#[link_name = "rustsimplicity_0_8_bitcoin_check_lock_distance"]
pub fn bitcoin_check_lock_distance(
dst: *mut CFrameItem,
src: CFrameItem,
env: *const CTxEnv,
) -> bool;
#[link_name = "rustsimplicity_0_8_bitcoin_check_lock_duration"]
pub fn bitcoin_check_lock_duration(
dst: *mut CFrameItem,
src: CFrameItem,
env: *const CTxEnv,
) -> bool;
#[link_name = "rustsimplicity_0_8_bitcoin_current_index"]
pub fn bitcoin_current_index(dst: *mut CFrameItem, src: CFrameItem, env: *const CTxEnv)
-> bool;
#[link_name = "rustsimplicity_0_8_bitcoin_num_inputs"]
pub fn bitcoin_num_inputs(dst: *mut CFrameItem, src: CFrameItem, env: *const CTxEnv) -> bool;
#[link_name = "rustsimplicity_0_8_bitcoin_tx_hash"]
pub fn bitcoin_tx_hash(dst: *mut CFrameItem, src: CFrameItem, env: *const CTxEnv) -> bool;
#[link_name = "rustsimplicity_0_8_bitcoin_tap_env_hash"]
pub fn bitcoin_tap_env_hash(dst: *mut CFrameItem, src: CFrameItem, env: *const CTxEnv) -> bool;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Note

🤖 Claude Opus 5.5 responding on behalf of @KyrylR

In b4cc29f

Major: these 8 declarations, and the wrappers at L173-L198, are unused and conflict with the WASM ABI.

  • Unused. c_jet_ptr already sends every Bitcoin jet through the generated jets_wrapper::bitcoin_*. Nothing references these, and the new jet::bitcoin tests still pass with this file reverted to base.
  • WASM ABI. They pass frameItem src by value, which is what WRAP_ exists to avoid ("Only the latter is permissible in WASM"). cargo +1.88.0 build --target wasm32-unknown-unknown -p simplicity-sys gives 8 wasm_c_abi warnings here and 0 on base. They also skip the WRAP_ postcondition assert on dst->offset.
  • Crash from safe code. They are 8 more safe pub fns that can be crashed without unsafe: passing CFrameItem { edge: null(), len: 0 } to current_index segfaults. The generated wrappers have the same signature; this adds more of them.
  • Commit message mismatch. The message names annex_hash and the tapbranch/tapleaf/taptweak builders, but only 8 functions are declared.

Suggest deleting the block. That also resolves the code-generation question in the PR description.

Agreed by: all ten (WASM ABI: Mythos, Fable; segfault: Astra ×3, Sol ×1)

Comment on lines +3 to +4
//! Checks that the generated Bitcoin jet CMR and cost tables agree with the
//! values recorded in libsimplicity's `primitiveJetNode.inc`.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Note

🤖 Claude Opus 5.5 responding on behalf of @KyrylR

In 9537b3b

Minor: this is a copy of the generated table, not a check against primitiveJetNode.inc.

The 4,065 lines never read the vendored C, so they can't catch drift between the C data and the generated Rust; update_jets.sh --check already covers that. They also have to be hand-edited on every libsimplicity bump that changes a cost.

Suggest a short cfg(test) parser over the vendored .inc that iterates Bitcoin::ALL, or dropping the file. All 428 CMRs and costs do match the C today.

Agreed by: all ten

Comment thread src/jet/bitcoin/tests.rs
@@ -0,0 +1,135 @@
// SPDX-License-Identifier: CC0-1.0

//! Tests that the Bitcoin backend actually executes (and prunes) tx-context /

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Note

🤖 Claude Opus 5.5 responding on behalf of @KyrylR

In b4cc29f

Minor: several tests here don't check what their names claim.

  • c_jet_env_is_the_tx_env compares a value with itself, because c_jet_env is self.c_tx_env().
  • bip_0340_verify_ptr_resolves can't fail: c_jet_ptr has an arm for every jet, so "Unwired jets still panic here" (L102) is not true.
  • TxHash, TapEnvHash and SigAllHash are only checked for output type 2^256, not their values.
  • check_lock_height uses lock time 0 with a final sequence, so a jet that ignored the environment would also pass.
  • This doc says "(and prunes)", but nothing calls prune.

Agreed by: Opus 5.5 ×2, Mythos, Fable, Astra ×3, Sol ×1

Comment thread src/analysis.rs
Comment on lines +190 to +191
// Note `deficit >= 2` here: the function returns early when the cost
// already fits, and a deficit of 0 or 1 cannot occur.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Note

🤖 Claude Opus 5.5 responding on behalf of @KyrylR

In c0bfd57

Minor: "a deficit of 0 or 1 cannot occur" is false.

Cost::from_milliweight(51_001) on an empty witness has a deficit of 1, and get_padding_size returns Some(1). On Bitcoin an empty item already satisfies is_budget_valid. Across deficits 1..70,000, this is the only case where the size is larger than needed.

Agreed by: Opus 5.5 ×2, Mythos, Fable

Comment thread src/analysis.rs
// and 5 bytes for larger values. The overhead subtracted must account
// for the actual CompactSize encoding length of the resulting annex.
// for the actual CompactSize encoding length of the resulting item.
let deficit = (weight - budget).0 as usize; // cast safety: 32-bit machine or higher

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Note

🤖 Claude Opus 5.5 responding on behalf of @KyrylR

In c0bfd57 (logic already on master)

Minor (already on master): the padding calculation ignores growth of the item-count prefix.

When the padding item takes the stack from 252 to 253 items (or from 65,535 to 65,536), the count's CompactSize grows by 2 bytes, and this calculation doesn't account for it. For example, 252 empty items with a 4 WU deficit returns 3 bytes when 1 is enough. It only over-pads, never under-pads.

The "padding must be minimal" test removes the whole item instead of shortening it, so it misses this.

Agreed by: Mythos, Fable, Astra ×3, Sol ×2

Comment thread CHANGELOG.md
Comment on lines +4 to +11
* Add `Cost::get_padding_size`, returning the chain-agnostic length of the padding witness
stack item needed to bring a cost within the witness budget, and `Cost::get_padding_bytes`,
returning the chain-specific bytes that fill it (an all-zero item on Bitcoin, an annex on
Elements). Deprecate `Cost::get_padding` in favour of these.
* `Cost::get_budget` and `Cost::is_budget_valid` now take `&[Vec<u8>]` instead of `&Vec<Vec<u8>>`
and compute the serialized witness length directly rather than via the Elements consensus
encoder, making them available under the `bitcoin` feature (which `elements` implies) as
well as `elements`.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Note

🤖 Claude Opus 5.5 responding on behalf of @KyrylR

In c0bfd57

Minor: this entry is incomplete.

It omits the main change: Bitcoin::cmr() and Bitcoin::cost() no longer panic. It also omits the libsimplicity bump, and it lists the private get_budget as changed API.

Agreed by: Opus 5.5, Mythos, Fable

Comment thread src/analysis.rs
Comment on lines +132 to +134
let budget = serialized_len.saturating_add(50);
let budget =
u32::try_from(budget).expect("Serialized witness stack must be shorter than 2^32");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Note

🤖 Claude Opus 5.5 responding on behalf of @KyrylR

In c0bfd57

Nit: this now panics where it used to saturate.

50 is added in usize before u32::try_from(...).expect(...); the old code converted first and then called saturating_add(50). Serialized lengths within 50 of u32::MAX now panic. This is only reachable with witnesses of about 4 GiB. Suggest converting first, then saturating_add(50).

Agreed by: Opus 5.5 ×2, Mythos, Fable, Astra ×2, Sol ×1

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.

3 participants