Transaction Chaining - #964
Conversation
Transaction chaining — benchmark results
Measured on my i9-12900K (16 cores / 24 threads, 123 GB RAM) with Claim 1 —
|
| legacy | chaining | |
|---|---|---|
| Σ padded height | 2 183 168 | 1 572 864 |
| prover time | 858 s | 599 s |
breakdown
chained: Forge -> Fix
Forge[0] height 1048576 422.1 s
Fix height 524288 177.1 s
TOTAL height 1572864 599.2 s
legacy: Collect -> Raise
RemovalRecordsIntegrity height 65536 12.1 s
CollectLockScripts height 4096 0.5 s
KernelToOutputs height 8192 0.8 s
CollectTypeScripts height 8192 0.7 s
Raise height 2097152 844.1 s
TOTAL height 2183168 858.2 s
≈30% cheaper.
Claim 2 — chaining N interactions beats N separate transactions
Not like-for-like: the chained fixture's successor spends its predecessors' unconfirmed outputs, which the legacy pipeline cannot express at all. The closest counterpart is N independent transactions raised and merged.
| N = 2 | legacy | chaining |
|---|---|---|
| Σ padded height | 5 414 912 | 3 670 016 |
| prover time | 2 119 s | 1 361 s |
breakdown
chained: 2 interactions
Forge[0] height 1048576 392.5 s
Forge[1] height 1048576 391.7 s
Chain[0] height 1048576 397.5 s
Fix height 524288 178.9 s
TOTAL height 3670016 1360.6 s
legacy: 2 separate transactions
RemovalRecordsIntegrity[0] height 65536 11.5 s
CollectLockScripts[0] height 4096 0.5 s
KernelToOutputs[0] height 8192 0.9 s
CollectTypeScripts[0] height 8192 0.8 s
Raise[0] height 2097152 839.9 s
RemovalRecordsIntegrity[1] height 65536 11.5 s
CollectLockScripts[1] height 4096 0.5 s
KernelToOutputs[1] height 8192 0.7 s
CollectTypeScripts[1] height 8192 0.8 s
Raise[1] height 2097152 850.4 s
Merge[0] height 1048576 401.7 s
TOTAL height 5414912 2119.2 s
≈36% cheaper. At the margin: one more chained interaction is Forge + Chain ≈790 s, against ≈1265 s for one more legacy transaction.
Why
Not the RRI inlining: proving RRI alone is 12.1 s, 1.4% of the legacy total. It is recursive verification which dominates all of these programs. Forge+Fix nets 3 fewer recursive verifications than Raise.
N = 4 compiles but has not been run. no_cache makes both columns pessimistic in absolute terms; the ratio is unaffected.
|
Thanks for the benchmark. Very cool that this is faster than "collect" + "raise"! As you mention, all that really matter is the number of in-vm verifications you have to do. Since "collect" alone (without the "raise" step) is still much faster than the "chaining" approach, I think that "collect" should stay as the default proving method that downstream wallets use. That being said, that's of course up to each wallet implementation and outside of our control. |
|
The auditing of the entire witness might be a bit excessive. I wonder what you pay for that in clock cycles. I'm thinking about this TASM: let audit_preloaded_data =
library.import(Box::new(VerifyNdSiIntegrity::<ChainWitness>::default()));Maybe it's OK? I think it's worth investigating what the witness auditing costs. We don't want that to become dominant in the future, for any transactions sizes. Consider using more "WitnessMemory" structures if auditing is too expensive. |
|
So we need to ban "update" on And we should ban negative fees throughout the Also: The edit:
|
|
Updated to latest |
|
Missing internal audits: LinkProof
SingleProof
Everything else is window dressing. |
…ions diagram Bring `transaction.md` in line with the current code: - fix stale witness field names in the field-usage table (`PrimitiveWitness`, `lock_scripts_and_witnesses`, `type_scripts_and_witnesses`) - document `merge_bit` semantics: set by the Merge branch, asserted false by the Collection/Raise branch - fix duplicate/skipped section labels (Update is E; Integral Mempool is a wishlist, not a validity variant) - clarify the opaque mutator-set-hash update condition - add an ASCII diagram of the legacy validity transitions (PrimitiveWitness -> ProofCollection -> SingleProof, with Merge and Update as self-loops) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First data structure of the transaction-chaining pipeline. `LinkKernel` composes a legacy `TransactionKernel` with a `Vec<AdditionRecord>` of thruputs, reusing the wrapped kernel's MAST leafs verbatim and carrying the thruputs as one extra trailing leaf (`LinkKernelField::Thruputs`). This keeps the legacy kernel fields at their original leaf positions so that the view from the type script is exactly a legacy transaction. Wires up the `chaintx` module. Tests cover the leaf-position drift guard against `TransactionKernelField`, verbatim reuse of the kernel leafs, thruput binding into the MAST hash, and BFieldCodec round-tripping. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The transaction-chaining analogue to `PrimitiveWitness`. Exposes secrets. Never broadcast. Consumed by `Forge` which produces a `LinkTx`. Stores two input kinds: - *confirmed inputs* -- like today's inputs, spent via removal records and membership proofs; - *thruputs* -- a predecessor's unconfirmed output, but since it is unconfirmed the data structure agrees with that of outputs. The `input_utxos` field is the concatenation of confirmed-inputs and thruputs. This way the type script sees a legacy transaction, but also sees the thruputs. Also: add and use test helper function `LinkKernel::empty()`. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Transaction-chaining analogs of `TransactionProof` and `Transaction`, respectively. Contrary to `TransactionProof`, `LinkTxProof` only has two variants, not three: the proof-collection stage is being skipped. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ess` Replace placeholder arbitrary strategy with one obtained from lifting a `PrimitiveWitness` strategy. Lifting means: reclassify the last k inputs as thruputs. No rebalancing needed. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`from_primitive_witness` reclassifies the tail of a `PrimitiveWitness`'s inputs as thruputs, which truncates the kernel's `inputs` leaf and thereby moves the kernel mast root. The type scripts are checked against that root, so their fee/coinbase/timestamp mast auth paths must be regenerated against the truncated kernel -- reusing the originals produces witnesses that fail type-script verification. Mirror of `update_with_new_ms_data`. Latent because `bfield_codec_round_trip` never runs the VMs; surfaced when writing the `LinkWitness::validate` reference predicate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Proof-free, tier-1 validator for a chained transaction -- the analog of PrimitiveWitness::validate, mirroring its structure check-for-check. Confirmed inputs are validated against the mutator set (removal-records integrity); thruputs are checked only for commitment-consistency (Forge does not match a thruput against a predecessor -- that is Chain's job); type scripts run unchanged over the combined confirmed || thruput input list. Adds the LinkWitness-specific rules the thruput/LinkKernel semantics require: cardinality, thruput commitments, and coinbase rejection. Three new WitnessValidationError variants (CardinalityMismatch, ThruputCommitmentMismatch, CoinbaseSet). Test suite as proptests over random instances (no proving involved, so no need for deterministic single samples): a positive lift-preserves-validity property, one negative per reachable branch built by poking a valid witness (or poking the PrimitiveWitness and lifting, so from_primitive_witness rebuilds the type scripts and the intended late-stage check fires), and a pinned lock-script coverage gap. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirror `SingleProofWitness` for the link pipeline. Only the `Forge` variant lands here; `Chain`, `Update`, and `Cast` follow with their witnesses and tasm programs. `Fix` is deliberately absent: it produces a `SingleProof`, not a `LinkProof`, so it belongs on `SingleProofWitness`. Discriminant 0 is pinned for `Forge` (with 1/2/3 reserved) since the `LinkProof` program branches on it. `TasmObject` is hand-written because the derive rejects enums -- same reason `SingleProofWitness` has one. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The name now says which tier it belongs to. It is the analog of `PrimitiveWitness`, not of `SingleProofWitness` -- a distinction that matters now that `LinkProofWitness` exists alongside it. Renames the module to match. Also picks up rustfmt on the touched file. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`Forge: LinkPrimitiveWitness -> LinkTx` is the entry point into the
transaction-chaining pipeline. It recursively establishes, against the
`LinkKernel` MAST hash, that a chained transaction's inputs and outputs are
legitimate and that every lock and type script halts.
Input integrity
- Authenticate the mutator-set accumulator, the confirmed removal records, the
thruputs, the outputs, and the constant coinbase / merge-bit leafs against the
`LinkKernel` MAST hash.
- Inline `RemovalRecordsIntegrity` non-recursively over the confirmed inputs
(the cost saving that motivates chaining): AOCL membership plus computed-vs-
claimed absolute index sets.
- Check thruput commitments and bind `|input_utxos| == |confirmed| + |thruputs|`,
so a phantom input UTXO cannot inflate the type scripts' input balance.
Output integrity
- `KernelToOutputs` absorbed: each addition record must equal the canonical
commitment of its output UTXO, binding the type scripts' view of the outputs
to the mutator set. Without it a prover could show the type scripts cheap
outputs while the kernel commits to expensive addition records.
- Thruputs and outputs share one `for_all_addition_records` loop (both are
`AdditionRecord`s equal to a UTXO's canonical commitment).
Recursive script verification
- Divine the inner (height-3) `TransactionKernel` MAST root -- the value the
scripts were proven against -- and authenticate it as the left child of the
`LinkKernel` root (one `hash` against the divined right sibling). It is kept
on the stack, reusing the now-dead `lkmh` slot, so both claim templates read
it without static memory.
- Lock scripts: verify one halting proof per input UTXO (`StarkVerify`) against
the inner root, marching `input_utxos` and `lock_scripts_halt` in parallel --
a thruput is still an input being spent and must be authorized.
- Type scripts: recollect the deduplicated, native-currency-first hash list over
`input_utxos || output_utxos` (absorbing `CollectTypeScripts`) and verify one
halting proof per unique type script against
`[inner_root, H(input_utxos), H(output_utxos)]`.
- Both loops assert their proof counts up front
(`WRONG_NUMBER_OF_{TYPE,LOCK}_SCRIPT_PROOFS_ERROR`), so a shortfall fails
cleanly rather than obscurely inside `StarkVerify`.
Witness and constructor
- `ForgeWitness` carries the input/output-integrity data plus `lock_scripts_halt`
and `type_scripts_halt`. `produce` is the sole production constructor -- it
proves every lock script and every unique type script -- and shares field
assembly with a private `build_from_parts`. A `#[cfg(test)]` `without_proofs`
builds proofless witnesses for the integrity-negative and MAST-derivation
tests, which never reach script verification (proving in each would time the
suite out).
Reference predicate
- `ForgeWitness::validate` is the proof-carrying reference predicate (analog of
`LinkPrimitiveWitness::validate`, sibling of `ProofCollection::verify`): it
re-derives the inlined integrity natively and *verifies* -- never runs -- the
lock and type scripts, so a `true` verdict guarantees the tasm `Forge` accepts
and a caller can gate the expensive proof on it. It splits into a cheap, pure
`validate_integrity` (exhaustively testable without proving) and the full
proof-verifying `validate`. Both are hand-written native predicates (the tasm
`run_rust` shadow stays test-only) and are not feature-gated, since everything
they reference is production-available.
Copied snippets and drift guards
- The confirmed-input loop is a deliberate copy of `RemovalRecordsIntegrity`'s,
whose hash is consensus-pinned; `forge_confirmed_loop_matches_rri` guards it.
- The three type-script-hash collection subroutines are copied instruction-for-
instruction from `CollectTypeScripts`; `forge_collect_type_scripts_matches_cts`
guards them (normalizing the copies' local call labels).
- `authenticate_mutator_set_acc` is a near-verbatim copy of RRI's, differing only
in the `MAST_HEIGHT` immediate; left unguarded on purpose (a guard would have
to special-case that lone immediate).
Also: `LinkPrimitiveWitness::validate` gains the matching output-side checks; the
Rust spec `source()` verifies the scripts in the same order as the tasm; and
Forge's assertion error ids are registered at 1_000_520..528.
Lock- and type-script coverage is complete; the remaining pipeline stages
(`Chain`/`Update`/`Cast`/`Fix`) are tracked in `chaintx/TODO.md`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`CollectLockScripts` / `CollectTypeScripts` are absorbed by `Forge` only in the dual (chaining) pipeline; they remain, unchanged and consensus-pinned, in the legacy pipeline. Reword the TODO note, which read as if they were removed outright. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…h `Forge` `Forge` inlined its own near-verbatim copy of the mutator-set-accumulator authentication -- the one duplicated snippet that had no drift guard. Replace it with the existing `AuthenticateMsaAgainstTxk` snippet, shared with the legacy `SingleProof` pipeline. Generalize the snippet over MAST height: - Add a `mast_height` field (was hardcoded to `TransactionKernel::MAST_HEIGHT`); the `MutatorSetHash` leaf index (6) is the same in both kernels, so only the height varies. The entrypoint keeps its name (no height suffix): imports are emitted sorted by name and no single program authenticates against two kernel heights, so a stable name keeps each caller's bytes identical -- the sole existing caller (`update_branch`) still emits `push 3`, and the `SingleProof` program hash is unchanged (its `test_program_snapshot!` verifies this). Wire `Forge` to import it at `LinkKernel`'s height: - Drop the ~60-line inline copy for a short call passing the AOCL / bagged-swbfi / swbfa pointers and `lkmh`. Imported after the four RRI-matching `kmalloc`s, so the confirmed-input loop's static addresses are undisturbed (`forge_confirmed_loop_matches_rri` verifies this). - `ForgeWitnessMemory` now carries `swbfi_bagged` and `swbfa_hash` as digests (like `update_branch`'s `UpdateWitness`) instead of the raw swbfi MMR; the swbfa hash is no longer divined. `source()` and `nondeterminism()` follow. The MSA authentication is now the shared, separately-audited snippet (`AuthenticateMsaAgainstTxk` has its own `MemPreserver` shadow test), removing `Forge`'s last unguarded copy. This changes `Forge`'s program hash, pinned by the snapshot in the following commit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add `test_program_snapshot!(Forge, ...)`, now that `Forge` is feature-complete (input/output integrity, recursive lock- and type-script verification, and the shared mutator-set-accumulator authentication), so any unintended change to the program hash is caught. Mark the `Forge` tasm item and its snapshot subitem done in `chaintx/TODO.md`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add two cheap (`without_proofs`) tasm negatives for the inlined `RemovalRecordsIntegrity`: - `bad_mutator_set_accumulator_is_rejected`: a mutator-set accumulator that disagrees with the kernel's `MutatorSetHash` fails authentication (`ROOT_MISMATCH`). Also the first negative to exercise the shared `AuthenticateMsaAgainstTxk` snippet's rejection path. - `bad_absolute_index_set_is_rejected`: a confirmed record whose claimed absolute index set disagrees with the one recomputed from its UTXO fails (`COMPUTED_AND_CLAIMED_INDICES_DISAGREE_ERROR`) -- a double-spend path if unchecked. Mirrors `removal_records_fail_on_bad_absolute_indices`. Update `chaintx/TODO.md`: tick these plus the items already covered (bad MAST binding via the `unauthenticated_*` tests, phantom UTXO via `phantom_input_utxo_is_rejected`, lock-script coverage via the count guard), and drop the items that don't belong on `Forge` -- the balance/fee properties are `NativeCurrency`'s (verified, not re-implemented, by `Forge`) and partition misclassification is not an error condition -- keeping a note that records why. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`Fix` recursively verifies a `LinkProof` and `Cast` recursively verifies a `SingleProof`, so each edge needs the other program's digest at program- construction time. Hardcoding both is impossible. Record the resolution: promote the `SingleProof` digest `D` to a public input of the `LinkProof` claim, copied verbatim through `Chain`/`Update`, used only by `Cast`, and instantiated by `Fix` as `own_program_digest()`. Includes the soundness induction, the audit-critical pass-through invariant, the accepted upgrade coupling, and a negative test per branch that touches `D`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Forge` was a standalone program with its own claim and its own pinned hash, even though `LinkProofWitness` already reserved discriminants for the four branches. Give the pipeline its dispatcher. `LinkProof` reads the link-kernel MAST hash off stdin, range-checks the witness discriminant at address 0, and dispatches. `Forge` becomes a `BasicSnippet` with the stack contract `[lkmh] *witness disc -> [scratch] *witness -1`, mirroring `SingleProof`'s merge and update branches. The digest slot belongs to the dispatcher and is scratch: `Forge` reuses it for the inner kernel root once `lkmh` goes dead, and the dispatcher pops it unread. Should a post-dispatch check ever need `lkmh`, it stashes it once in `LinkProof` rather than making each branch hand it back -- a branch returning the inner root instead of `lkmh` returns a well-formed digest of the same transaction, which would bind such a check to the wrong tree without crashing. `Forge` must be imported first: four of its `kmalloc`s have to land at `RemovalRecordsIntegrity`'s addresses, and `forge_confirmed_loop_matches_rri` compares emitted instructions, `push`ed addresses included. The discriminant range check is not redundant with `NO_BRANCH_TAKEN_ERROR`: branches signal "taken" by leaving `-1` in the discriminant's slot, so a witness claiming `-1` would otherwise sail through the dispatcher untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…patcher `Chain` and `Update` recursively verify a `LinkProof` against a claim that names `LinkProof` itself. Neither can hardcode that digest -- it is not known while the program is being built -- so both need `own_program_digest()`, the same move `SingleProof` makes for its self-recursion. The digest sits at the bottom of the initial stack and `dup` reaches only 16 deep, so the dispatcher must copy it before pushing anything: read it first, hand it to every branch below `lkmh`, and pop it after dispatch. `Forge` is unaffected. Its frame sits above the digest and it addresses that frame relatively, so it simply leaves the digest buried. Re-pins the `LinkProof` program hash.
`Forge` authenticated its MAST leafs with an inline closure. Every branch has to do the same thing, so lift it into `AuthenticateLinkKernelField` -- the `LinkKernel` analog of `AuthenticateTxkField`, differing only in tree height and leaf enum. Deliberately a copy of the legacy snippet rather than a shared, height-parameterized one: that snippet is baked into the consensus-pinned `SingleProof` hash, so widening it could only ever risk that hash, for no gain here. (`AuthenticateMsaAgainstTxk` *was* worth parameterizing: its body is substantial and its one legacy caller could absorb the change.) The extraction splits two jobs the closure had conflated. Three of `Forge`'s five leafs hash a witness field, which is what the snippet does. The other two push a consensus-fixed constant digest straight into `merkle_verify`, which asserts the field's *value* at the same time -- strictly stronger, and not something the snippet can express. Those keep their own path, and the two constants move to `link_proof.rs`: no branch owns them, every branch owes them. Imported after the four RRI-matching `kmalloc`s, alongside the other verifier-side snippets, so those addresses cannot shift and `forge_confirmed_loop_matches_rri` keeps holding. Re-pins the `LinkProof` program hash.
`Chain: LinkTx * LinkTx -> LinkTx` combines two link transactions, cutting through the thruputs that the operands' outputs resolve. Transaction-chaining analog of `single_proof/merge_branch`, with `ChainWitness` playing the part of `MergeWitness`; unlike `ForgeWitness` it needs no memory projection, since everything it carries is read from RAM. Cut-through is one witness-supplied multiset removed from the concatenated outputs *and* the concatenated thruputs. Same list on both sides, so a record can only leave the output side by leaving the input side with it -- value conservation by construction -- and matching is on the addition record, i.e. on the UTXO's canonical commitment. A thruput no predecessor output resolves can therefore never be cancelled, which is what makes a fabricated one inert rather than inflationary. Also established: inputs and announcements are the concatenations; the fee is the sum, with both operand fees bounded (hence non-negative); the timestamp is the later of the two; all three kernels share one mutator-set hash; and the chained kernel carries no coinbase and no merge bit. The operands' coinbase and merge-bit leafs are deliberately *not* re-checked. Every branch asserts both on the kernel it produces, so an operand that verifies has them by induction. Any branch added later owes the same assertion. The recursion runs *last*, unlike `merge_branch`, which verifies its operand proofs first. Ordering is immaterial to soundness -- every assertion must hold either way -- and putting the cheap self-contained checks first lets a negative test drive any of them with a proofless witness, the same reason `Forge` ends with its script verifications. Ten negatives ride on that and run in about a second; the positive test, which cannot, pays for two real link proofs. `D`, the `SingleProof` digest, is not yet part of the operand claims: it does not enter the `LinkProof` claim at all until `Cast` gives it a consumer. See the tracker. Re-pins the `LinkProof` program hash.
Cut-through must be maximal: a chained `LinkKernel`'s outputs and its thruputs are disjoint. The two cut-through equations did not establish that. They hold for *any* sub-multiset of the intersection, so a prover could name a short `cut_through` and leave a matching (output, thruput) pair standing -- both sides still balance, since the record simply never left either one. Disjointness is what pins the cut-through set to the whole intersection. For a record the operands hold `a` times as an output and `b` times as a thruput, the equations force cut-through multiplicity `c <= min(a, b)`; disjointness of the `a - c` and `b - c` survivors then forces `c = min(a, b)`. So the new assert is exactly maximality, not an approximation of it. Implemented as a quadratic scan -- `All` over the chained thruputs, each one looked up in the chained outputs with `Contains` -- because disjointness does not reduce to a multiset equation the way the surrounding checks do. An `AdditionRecord` is a `Digest` in a one-field struct, so records compare unhashed, unlike the multiset comparisons, which hash first because that is what `MultisetEqualityDigests` eats. The list being scanned reaches the inner function through static memory, the inner function taking only its element. Both lists are already authenticated against `lkmh` by the two `assert_cut_through` calls the new one follows; it only re-reads them. `chained_kernel` already cancelled everything it could, so no witness this repo builds is affected -- only a hostile prover could reach the gap. Mirrored in the Rust shadow, which `test_assertion_failure` requires to reject too. `non_maximal_cut_through_is_rejected` drives it: pop a record off `cut_through` and put it back on *both* sides, leaving both equations satisfied so that nothing but the new assert stands between the witness and a valid chain. Renumbers three error ids to keep the constants in program order; they are file-local. Repins the `LinkProof` program hash, which any new assertion moves. Also picks up rustfmt's re-indentation of `Forge::authenticate_field`, which the tree had drifted from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The TODO item asked for mixed provenance -- `Chain(Forge'd with D₁, Cast'd with D₂)` -- to be rejected, calling it the case that would otherwise launder a junk-`D` `Cast` into a real chain. Reading the branch, that hazard does not exist. `Chain` never compares two digests because there is only one: `D` is read from the public input into a single static slot, and `generate_link_proof_claim` appends that one value to both operand claims. Operands supply no digest of their own, so mixed provenance is unrepresentable rather than rejected, and both operands are forced into the same `Link[D]` family by construction. How the junk operand was produced is irrelevant; `Chain` only ever checks the claim. What that argument does not cover is whether both claims are *checked*, and there the suite had a real hole. Every operand negative corrupts both operands at once -- `operand_forged_under_another_single_proof_digest_is_rejected` forges each under `D'`, `operand_proof_must_attest_to_its_own_operand` swaps them -- so verifying the left operand alone trips all of them. Deleting the right `verify_operand` call, a whole missing recursion rather than a subtle slip, would have gone unnoticed by the entire test suite. `each_operand_is_verified_against_the_claims_d` closes that. Exactly one operand is forged under `other_d`, in each direction, so the surviving verification is the only thing that can fire. It costs cache hits rather than forges: both digests over both operands are proofs the neighbouring tests already make. Two construction notes, both recorded in the doc comment. The non-determinism is a consistent witness's, with the junk operand substituted afterwards -- the digest stream is extracted per (proof, claim) pair, so it cannot be built for a mismatched pairing at all, which is the same reason `operand_proof_must_attest_to_its_own_operand` substitutes rather than constructs. That also sharpens the test: were the substituted operand's verification skipped, its proof would go unread and the leftover stream unused, and the run would succeed. And only the tasm is run, unlike its neighbours, because the rust shadow's `verify_stark` unwraps FRI errors and so aborts instead of returning -- the shadow's error handling, not the branch's behaviour. Not mutation-checked by deleting the right `verify_operand` call: that changes the program hash, invalidating every cached `LinkProof` and forcing a full re-forge. The evidence is direct anyway -- with the junk proof on the right, the left operand is good, so `stark_verify` runs and succeeds for it, and the run still fails inside `stark_verify`. The right operand's proof is the only other one in play. Mirrored for the left. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both used a `[~]` marker whose meaning is written down nowhere, so two resolved items read as pending. Neither needed work; both needed saying so plainly. Proof/claim binding -- a valid `LinkProof` for `[lkmh, D₁]` not verifying against `[lkmh, D₂]` -- is now `[x]` with an explicit resolved status and the argument spelled out rather than compressed. The check cannot fail: the challenges a proof answers are derived by hashing a transcript seeded with the statement itself, so a proof is bound to its statement by construction and the test would be asserting a property of Triton VM. What the entry was really aimed at is the risk that is ours -- `D` read at runtime and then never put in the claim, leaving it decorative and every digest interchangeable, which is universal forgery. That risk exists only because `D` is a parameter rather than a constant, and the entry now names the asymmetry behind it: Fiat-Shamir binds a proof to whatever statement you check it against, and says nothing about whether you built the right statement. Two tests cover the second part, one per side -- `link_proof_claim_shape_is_pinned` for the prover's claim builder, `operand_forged_under_another_single_proof_digest_is_rejected` for the verifier's program -- and both are needed, being separate pieces of code that could each forget `D`. The `LinkProof`-hash-stability entry is deleted outright. One of its two justifications had expired: it said there was no second `SingleProof` version to parameterise over, which stopped being true when the `Fix` branch made gamma and delta different programs. The other, the `test_program_snapshot!` backstop, is already recorded where that pin is logged. And the real reason the hash cannot drift is already a governing invariant at the top of this file -- `D` is a parameter of the `LinkProof` claim, never a constant inside it -- which is what makes `LinkProof` a unit struct with no channel through which a `SingleProof` version could reach its program text. Nothing was left for the entry to carry. No `[~]` markers remain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every `Fix` test so far stops at running the program, which establishes that the branch's asserts hold and nothing more. What a block carries is a proof, answering a claim built by `single_proof_claim` rather than by the witness -- two constructions that have to agree on the program digest and the claim's Triton VM version both, the version being part of the Fiat-Shamir transcript and so not a mismatched constant but a proof that answers nothing. `produce_and_verify_single_proof` proves the fix witness and verifies the result against that claim, returning it so a caller can say what the transaction leaving the pipeline turned out to be. Two tests use it. `forge_then_fix_yields_a_verifying_single_proof` takes the same route as `fix_accepts_a_resolved_link_transaction` -- the same link transaction, hence a cache hit -- and adds the last step. `cast_then_fix_yields_a_verifying_single_proof` is the one where the `Fix`/`Cast` cycle closes with real proofs on both sides: a genuine `SingleProof` recursively verified by `Cast` under digest `D`, and that same `D` instantiated by `Fix` as `own_program_digest()`. The transaction is proven under delta, not gamma as `cast.rs`'s own fixture is. It has to be: `Fix` names its own program's digest, so a gamma-proven transaction can enter the chain but never leave it. The two rule sets share a claim version, so nothing else stands in the way. It is also the `Cast` round-trip. The fixed transaction's claim is asserted equal to the original transaction's: `Cast` adds nothing and `Fix` takes nothing away, so the composition is the identity on what a block checks. Both run cold in ~21 minutes together. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`prop_positive` had never run over a chained witness whose `new_kernel.thruputs` is non-empty, so nothing showed `Chain` accepting an honest carry-forward. Both existing positives chain to zero thruputs: one pairs a successor with the single predecessor resolving all of them, the other uses `from_primitive_witness(pw, 0)` throughout. The negatives bound the cut-through set from every side -- `cut_through_on_unequal_commitments_is_rejected` below the outputs, `non_maximal_cut_through_is_rejected` above, `a_thruput_cannot_be_cut_through_twice` on multiplicity -- with no positive to be non-vacuous against. `thruputs_resolve_in_two_stages` supplies it. The successor is funded entirely by unconfirmed money, one thruput from each of two predecessors. Chaining the first cancels one thruput and leaves the other standing; that intermediate is proven and chained onto the second, which empties the list. Both stages assert their cut-through size and thruput count, and both run through `prop_positive`. The intermediate has to be proven because `ChainWitness::chain` takes `LinkTx`s and panics on anything not proof-backed; the second stage is consumed by nothing, so it is run rather than proven, as `chain_accepts_a_chain_produced_operand` already does with its outer chaining. This is also the shape the mempool will produce, parents arriving separately rather than all at once. `chainable_link_primitive_witnesses` could not express it: it builds the one predecessor resolving the whole reclassified tail. The predecessor construction is extracted to `predecessor_resolving(&pw, range)`, taking a range rather than a count, and the old function now delegates to it with the tail range -- same behaviour, and the caller list is unchanged. All 24 `chaintx::chain::` tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two `→ Chain → Fix` entries keep their open boxes but name one remaining gap instead of two: the proven-`SingleProof` half is done on both legs, leaving only the `Chain` in the middle. `Cast` round-trip goes to `[x]`. The thruput-input-integrity entry keeps its box and loses its "needs a fixture that cannot be expressed" tail, which is no longer true. The block end-to-end entry records that its verification half is done and parks the rest on Integration. Separately, the Triton VM proof-version item was describing a state of the world that no longer exists. It said delta is at `V6`, ahead of the linked dependency, and that a delta proof produced today would carry version 5 and answer nothing. Neither holds: there is no `V6` variant, delta tracks the linked VM at `V5`, and delta proofs are producible -- which is what the two full-pipeline tests rest on. The tripwire it named does not exist either; it is `newest_rule_set_tracks_the_linked_triton_vm`. The mechanism is already documented at the site (`consensus_rule_set.rs`) and enforced by that test, so the entry keeps only what neither can say -- that a bump is expected -- in one line. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The four test sections had drifted into prose: entries carried their own rationale, their provenance from the legacy test they mirror, notes to future readers, and in several cases an argument for why some neighbouring test exists. Reading them told you what someone was thinking, not what is covered. Every entry is now the test's function name and one sentence saying what it does, standing on its own -- no `←` back-references to legacy tests, no motivation, no cross-entry commentary. Items with no test yet keep their open box and describe the test in the same form. Entries that had never named a test now do: fifteen across `onto Chain` and `onto Update` described a property and left the reader to find it. Four tests that exist but were unlisted are added (`chain_accepts_a_predecessor_successor_pair`, `update_accepts_a_forged_link_transaction`, `update_accepts_an_all_thruputs_link_transaction`, `forge_confirmed_loop_matches_rri`), and one name was simply wrong: `chained_outputs_must_be_the_operands_outputs` is `chained_outputs_must_be_union_of_outputs_of_operands`. Every backticked identifier in the file now resolves to a real function. Three entries are not tests and say so in a line each rather than a page: the proof/claim binding argument, `Chain`'s mixed provenance, and `Update` re-targeting `D`. The first keeps its "not to be re-opened", which is the only operational thing it said. Net -138 lines. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`extra_output_utxo_fails_cardinality` and `tampered_output_randomness_fails` have existed in `link_primitive_witness.rs` since the predicate was written and appeared nowhere in this file, so the output side of the proof-free tier read as untested next to its input and thruput counterparts. The second is the output-side inflation path: an output UTXO whose sender randomness no longer commits it to the kernel's addition record gives `OutputCommitmentMismatch`. It is the proof-free counterpart of `output_utxos_unbound_to_addition_records_is_rejected` on `Forge`, and the twin of `tampered_thruput_randomness_fails` already listed beside it. Also names the error variant the input-cardinality entry produces, matching its new output-side neighbour. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Point `BasicSnippet` at its `tasm_lib` path, drop two redundant explicit link targets, and demote the two links to the `pub(crate)` `no_thruputs_subtree_root` -- they resolved only under `--document-private-items`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A benchmark target is an external crate, so nothing under `#[cfg(test)]` is reachable from one. Move `predecessor_resolving`, the chainable-pair constructors and `forge` into a `test-helpers`-gated `chaintx::test_helpers`, so the tests that own these fixtures and the benchmark that measures them build the same thing rather than two fixtures that have to be kept in step. Adds `fan_in_link_primitive_witnesses`: one successor funded entirely by `n` predecessors, one thruput apiece -- the shape an N-way chain collapses from. Also adds `ChainWitness::link_kernel`, mirroring `CastWitness::link_kernel`, since a caller that proves a chain needs the kernel to pair with the proof and `new_kernel` is `pub(super)`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two benchmarks, one per performance claim the chaining pipeline rests on: `Forge + Fix` against `Collect + Raise` over one transaction carried both ways, and `N` chained interactions against the closest thing the legacy pipeline admits, `N` independent transactions raised and merged. Each stage is traced for its padded height and then proven, and both numbers are reported. The prover is called directly rather than through the job queue: under `test-helpers` -- always on for a benchmark target, via the crate's dev-dependency on itself -- the queue serves proofs from the on-disk cache, which would time the disk instead of the prover. The lock-script and type-script halting proofs are excluded from both routes. `Forge` and `ProofCollection` prove exactly the same ones, so they cancel; they are produced through the cache here, as inputs to the stages that recurse into them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both §Benchmarks entries are now measured rather than pending. §Motivation claimed the cheaper initiation came from inlining `RemovalRecordsIntegrity` non-recursively. It does not: proving the inlined programs separately is a rounding error against either route's total. The saving is that the chained route recursively verifies three fewer proofs -- `Raise` verifies the four collection proofs on top of the script proofs, `Forge` verifies only the script proofs, and `Fix` gives one back by verifying the `LinkProof`. Recursive verification is what dominates all of these programs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The imports rewritten to point at `chaintx::test_helpers` were inserted where the old ones sat rather than where rustfmt sorts them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Revise the plan so the re-targeting branch may move a thruput into the confirmed inputs once the mutator set has confirmed it, replacing "index sets unchanged" and "thruputs byte-for-byte" with one coupled multiset pair. Records the per-entry commitment / AOCL / index-set equations, forbids promoting two thruputs at one AOCL leaf, and notes that the branch thereby becomes a second `RemovalRecordsIntegrity` site. Also: the stranded-successor motivation this fixes, the announcement payload that makes promotion permissionless for self-perpetuating UTXOs, the mempool split by preimage availability, and the new test list. Rename the branch from `Update` to `Advance` while here. `UpdateWitness` named two different types in this repo -- this one and `single_proof/update_branch`'s -- and the docs paid for the collision in circumlocution; `Advance` also survives promotion, which `Update` would not have. Plan only: percolating the rename into the code is tracked as its own item. Every remaining `Update` in this file is the `SingleProof` one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The witness `Update` will read when a thruput confirmed since the old mutator set moves into the confirmed inputs. One `Promotion` is the tuple `Forge`'s confirmed-input loop works on -- item, sender randomness, receiver preimage, AOCL leaf index -- plus that leaf's membership proof against the *new* AOCL. It names neither the thruput it retires nor the input it becomes: both are derived, and the two multiset equations do the matching. The set lives in the memory image, `UpdateWitness` being its own, so the loop count is a list length and the loop re-reads its operands rather than stashing them in `kmalloc` slots. The path digests still have to reach the digest stream for the Merkle-traversal instructions; the memory copy is redundant but harmless. The constructor now asserts both equations, the strictly ascending leaf indices and each entry's AOCL membership, so a bad witness fails in milliseconds rather than after a proof. The branch does not read any of it yet: every call site passes an empty set, which is the pre-promotion behaviour exactly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Merkle-traversal instructions read authentication paths off the nondeterministic digest stream, so the paths go there in promotion order -- after the field authentications, before `StarkVerify`'s digests, which is where the loop will sit: late enough that the thruput and input lists it compares are bound to their kernels, early enough to precede the recursion. Nothing interleaves and nothing joins the individual-token stream, `P` being memory-resident: the loop takes one path per iteration off this stream and everything else out of the entry. An empty `P` contributes no digests, so every witness built today produces the stream it always did -- the six cheap negative tests still pass, and the program is untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The branch now reads the promotion set. A loop over `P` commits each entry to its addition record, verifies that commitment against the new AOCL at the leaf index the entry names, derives the absolute index set from the same four operands, and appends the two results to the lists the equations compare: a copy of the new thruputs, and the hashed index sets of the old inputs. Two multiset comparisons then stand where two byte-equality assertions used to. No static memory. The loop's whole state is an eight-word stack invariant -- the accumulator, the two lists, the running lower bound, the count, the cursor -- and its deepest reach is `dup 14`, so nothing needs a `kmalloc` slot. The lower bound makes the leaf indices climb strictly, which is what forbids two promotions at one AOCL leaf: they would put two removal records with the same absolute index set in one kernel. Block rule 2.c rejects that anyway; catching it here keeps a `LinkProof` from ever attesting to it. Thruputs and input index sets stop being carried over byte for byte, so two negatives now fail at the equations instead of at the Merkle root, as the tracker predicted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eight negatives, one per way a promotion can be wrong: a promoted thruput the old kernel does not list, a promoted input whose index set is not the computed one, a commitment that is not in the new AOCL, a membership proof for the wrong leaf, a promotion retiring no thruput at all, either half of the coupled pair moving alone, two promotions at one AOCL leaf, and a valid pair in the wrong order. They run on a deterministic fixture with a mock old link proof, the way the existing negatives do: the recursion is the last thing the branch does, so every assertion here fires before a proof is looked at, and none of the eight pays for proving. `promotable` now also returns *decoys* -- the same tuples built for the inputs that stay confirmed. A decoy names a real leaf of the new AOCL and carries a valid membership proof, so it passes every per-entry check and still retires no thruput. That is the only shape that reaches the first promotion equation; every other wrong-UTXO poke fails at the membership check first. `update_promotes_every_thruput` adds the rescue in full: a link transaction with no confirmed inputs, whose thruputs all leave in one advance. It stops short of asserting the result `Fix`es, `Fix` not existing yet. No `promotion_loop_matches_rri` guard, and the tracker now says why. `Forge` needs its guard because it reimplements RRI's loop inline; this loop calls the snippets RRI is built from, so the shared core cannot drift without changing under both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rely on two version of tasm-lib/triton-vm. - Parameterize `NewClaim` and every claim-generator snippet over the rule set's proof version, so gamma programs keep pushing 5 while delta programs push 7. Entrypoint labels are unchanged: the label ordering decides program layout, so renames would break the pinned digests. - Parameterize update, merge, block program - Stamp every Rust-side sub-claim (proof collection, lock and type scripts, update/merge operand claims, block appendix) with the rule set's proof version.
Also rename "legacy pipeline" to "singple-proof" pipeline. And fix some comments, and some indentation. No consensus programs were changed by this commit.
…eline
- Disallow negative fees throughout the chain pipeline: Cast and Forge,
the pipeline's entry points, authenticate the kernel's fee leaf and
assert the fee is a non-negative amount in range; Chain's sum checks
and Update's unchanged-field check extend the rule by induction.
- Disallow Update when the link's inputs and thruputs are both empty:
such a link commits to nothing the mutator set moves, and updating one
would give composers a shortcut past the mempool.
- LinkProof writes the SingleProof program digest D to standard out, and
every LinkProof claim carries D as its output as well as (reversed) in
its input: { program, input: [lkmh] || [D], output: [D] }.
- Re-record the LinkProof and delta-SingleProof program digest pins.
An extra proof item is added upstream, for the new (canonical) version of Triton VM.
Change MempoolTransaction's transaction field to a sum over the two pipelines (AnyTx), keyed by the wrapped kernel's txid so a link and its Fix'd form contend for one slot. insert_link follows the standard replacement contest with links as witness/intermediate tier; a standard transaction is not replaced by its casted version. Links with a mined thruput are evicted on block update, as they can never Fix.
Three new p2p message types matching those for transactions in the single proof pipeline. Shares admission check with that of existing transactions. Further check: link txs are only accepted from peers if their thruputs match transactions in the mempool.
- Send chain-transactions, spending outputs from the mempool - New tx-upgrader task: fix chain-tx. Performs the "fix" transformation of chain-transactions with an empty set of thruputs. Done on 3rd party transactions. - Composer checks for fixable (no thruputs chain-tx) before constructing expensive nop. - bump version to v0.16.1 to activate chain p2p messages for integration tests. - resync own chain tx in mempool if they're still confirmable (input hasn't been mined and consumed output still in mempool).
6266eec to
878edf7
Compare
12f9a14 to
3ed57f0
Compare
In order to clearly be able to distinguish between an "update" on the single proof-pipeline and on the chain-tx pipeline, we name the operation that updates the mutator set of a chain-tx "advance". This operation can also "promote" thruputs to (confirmed) inputs, so it makes sense it gets a unique name. Also adds a bound to the jump length used when iterating through a list of promotion witnesses. This changes some program hash snapshots.
3ed57f0 to
293aa70
Compare
Crash VM execution if some list lengths are not valid u32 numbers. The main motivation for this is that I saw an `add` of two unchecked list lengths and wanted to disallow this add wrapping around the b-field element "max" value.
It can be divined in, or derived. This commit opts for deriving, as that just feels safer to me.

Very much work-in-progress. Please see TODO.md for a motivation and progress tracker.