Skip to content

docs(blockchain): make every Operation carry a proof and gas independent of it - #431

Open
thomaslavaur wants to merge 1 commit into
masterfrom
tl/fix_issue_proof_length
Open

docs(blockchain): make every Operation carry a proof and gas independent of it#431
thomaslavaur wants to merge 1 commit into
masterfrom
tl/fix_issue_proof_length

Conversation

@thomaslavaur

Copy link
Copy Markdown
Contributor

Audit issue #136 reports that one block id can carry two bodies and produce two ledger states, because gas is billed off proof bytes that nothing in the chain of commitments covers. The free slot is a CHANNEL_CONFIG on a channel that does not exist yet, whose proof is never inspected on any path. This PR closes the class from the other side: every Operation carries exactly one proof, the genesis configuration proof is verified against a threshold of 0 instead of being ignored, and the Execution Gas of an Operation is derived from the Operation and the state it is validated against rather than from the proof accompanying it.

Reviewer Orientation

Read Motivation first, then the audit issue, whose failure sequence is what the three normative changes below are answering. The review question is whether the number and the shape of every proof is now a function of the Operations and the state alone, so that two bodies sharing a mantle_txhash can no longer be priced differently.

# Priority Document / Change What to look for
1 Critical Start here, Mantle: the genesis configuration proof that the else branch now verifies instead of falling through, and that a threshold of 0 accepts exactly one proof, the empty one
2 Critical Mantle: Execution Gas as a function of the Operation and the state that every threshold pricing a channel Operation is read from the channel state and never from the proof, and the 0 case for a channel that does not exist yet
3 High Mantle: every Operation carries a proof that no Operation of the ten opcodes was relying on the None case
4 High Mantle Transaction Encoding: the channel threshold proof productions that the three channel proofs encode identically, and that the signer index is now on the wire where it was previously missing
5 Low Gas Cost Determination and the other Chores skim, terminology alignment, a bracket typo and the revision rows

Status tracker

  • 🚧 Raw (make sure that all below is completed)
    • Template applied
    • Authors filled in
    • Authors agree on the RFC content
  • 📘 Draft (make sure that all below is completed)
    • All dependent specifications added (Notion backlinks checked)
    • Specifications to deprecate added, if applicable
    • Specifications to retire added, if applicable
    • Research Lead assigned, or Project Lead assigned if the Research Lead is an author
    • Relevant Research Domain Experts assigned (cannot be authors)
  • ⚙️ Verified (make sure that all below is completed)
    • Researchers’ comments addressed
    • All logical changes documented
    • All Research reviewers approve the latest version
    • Engineering Lead assigned
    • Relevant Engineering Domain Experts assigned
  • 🔀 Merged (make sure that all below is completed)
    • Engineers’ comments addressed
    • Every change added to the change log
    • All Engineering reviewers approve the latest version
    • Specification version numbers assigned
    • Implementation reviewed and merged
    • Branch updated to master and all conflicts resolved
    • PR merged

Change log

Revision Description Date
v1 Initial PR description 2026-08-31

Motivation

The mantle_txhash covers the Operations of a Mantle Transaction and nothing else, and the block body root is built from that hash, so the proofs a transaction carries are outside every commitment a block header makes. That would be harmless if the proofs were only read by verification, which either accepts or rejects. They are not: the mandatory fee reads them twice, once through the encoded size of the signed Mantle Transaction and once through the thresholds pricing the channel Operations. A quantity that no commitment pins is therefore an input of the ledger state a block produces.

Audit issue #136 shows what that costs. A CHANNEL_CONFIG sent to a channel that does not exist yet is authorized by nobody, since there is no accredited key to authorize it, and the specification said so by leaving the proof unmentioned in that branch. An implementation reading it built a validation path where the proof of a channel creating configuration is never inspected, so a proposer could win a slot, build one header and two bodies whose proofs differ only in how many signatures they carry, and send one to each half of the network. Both halves recompute the same body_root and accept the same header, and then disagree on the gas the block consumed. Sized loudly the fat body fails its balance check and the network partitions on the first message, sized quietly the two halves diverge on fee market state with nothing surfacing the disagreement.

The specification can remove the degree of freedom the attack needs, which is that two well formed proof lists exist for the same Operations under the same state. Once the variant of every proof is fixed by its Operation and its number of signatures is fixed by the state, the proof list has one shape, one encoded size and one gas cost. The failure then requires forging a signature rather than choosing how many to send, and the same reasoning covers every Operation added later, since a new Operation gets its proof shape from the same two inputs.

Proposal

Three changes, in Mantle, and their consequence in Mantle Transaction Encoding.

op_proofs loses its None case. Every Operation carries exactly one proof, of the variant that Operation requires. None of the ten opcodes was using the None case, each of them already defining a proof in its own Proof section, so this removes a possibility rather than a behavior.

A CHANNEL_CONFIG creating a channel is verified rather than skipped. The branch that previously only checked the parent now runs the same Multiple Ed25519 Signatures Verification as the branch for an existing channel, against an empty accredited key list and a threshold of 0. That routine asserts that the number of signatures equals the threshold, so a threshold of 0 accepts exactly one proof, the one carrying an empty signature list and an empty index list, encoded as a count of 0 and nothing else.

The Execution Gas of an Operation is stated to be a function of the Operation and the state it is validated against. Where an Operation is priced proportionally to a threshold, that threshold is the one held in the channel state, and it is 0 for a channel that does not exist yet. The proof is not an input.

The encoding follows: the OpProof alternatives gain ChannelConfigOpProof and ChannelTransferOpProof, which were specified in Mantle but had no production, and the three channel proofs share a ChannelMultiSigProof production that carries the index of the signing key alongside each signature.

Discussion

Why not commit the proofs in the transaction hash

The audit's primary recommendation is to include op_proofs in the transaction identity hash. It cannot be done at the mantle_txhash, which every proof is bound to as a public input: hashing the proofs into the value they sign is circular. It could be done one level up, by making the body root leaf a hash over the full encoded signed Mantle Transaction, which is the audit's second formulation. That is a change to Block Construction rather than to Mantle, it changes what a block id is, and it is worth considering on its own merits.

This PR does not depend on that decision. Committing to the proof bytes would make the two bodies distinguishable, so the substitution would produce two block ids instead of one. Removing the freedom makes the second body impossible to build in the first place, whichever identity a block has. The two are complementary, and only the second one keeps holding for Operations that do not exist yet.

What this does and does not pin

The mantle_txhash still does not determine the proof bytes, and this PR does not claim it does. What it determines, together with the state, is the shape of op_proofs: how many entries it has, which variant each entry is, and how many signatures each entry carries. Every proof variant has a size fixed by that number, so the encoded size of the signed Mantle Transaction, and with it the permanent storage gas, is a function of the Operations and the state. The Execution Gas is one by construction after this PR. Two bodies sharing a mantle_txhash therefore consume the same gas, which is the property the divergence needed.

Substituting signature bytes for other valid signature bytes remains possible for whoever can produce them, which is the holder of the accredited key. It changes no size, no threshold and no gas, so it leads to the same ledger state.

The cost of a channel creating configuration

Pricing a CHANNEL_CONFIG at EXECUTION_CHANNEL_CONFIG_GAS * configuration_threshold with a threshold of 0 makes a channel creating configuration cost no Execution Gas. This is the honest answer of the linear formula, which prices the signatures verified, and a channel creating configuration verifies none. The Operation is not free: it pays permanent storage gas proportional to its encoded size, which for a configuration carrying a list of accredited keys is the dominant component, and it pays it in a Mantle Transaction whose balance must cover the mandatory fee like any other. A reviewer who considers a floor preferable should say so, the alternative being an affine cost with a base term.

Backwards compatibility

The removal of the None case is not a wire change, no encoding ever carried a discriminant for it. The ChannelMultiSigProof production is a correction of the specification rather than a change of the encoding: the previous ChannelWithdrawOpProof production omitted the signer indexes that the ChannelWithdrawOpProof structure of Mantle has always carried, so a decoder written from it could not have interoperated. The order is the one the implementation uses, verified against calculate_channel_multi_sig_proof_byte_size in core/src/proofs/channel_multi_sig_proof.rs, which is 2 + N * (ED25519_SIGNATURE_SIZE + 2).

The one behavioral change for a conforming implementation is the genesis configuration proof. A CHANNEL_CONFIG creating a channel with a non-empty proof was accepted before and is invalid now, and its Execution Gas drops from EXECUTION_CHANNEL_CONFIG_GAS times the number of signatures it happened to carry to 0. Both are consensus affecting and must land together.

Details

1. Every Operation carries a proof

Mantle drops the None alternative from the signed transaction:

 class SignedMantleTx:
     tx: MantleTx
-    op_proofs: list[OpProof | None] # each Op has at most 1 associated proof
+    op_proofs: list[OpProof] # each Op has exactly 1 associated proof

and states in the first validation step what the list must hold, the length assertion being unchanged:

-1. We have a proof or a `None` value for each operation.
+1. We have exactly one proof for each Operation, of the variant that Operation requires.
     assert len(op_proofs) == len(ops)

Each of the ten opcodes already specifies its proof in its own Proof section, so no Operation loses a way of being expressed. Genesis Block already required one entry per Operation of the type that Operation requires, its entries being placeholders that are not verified, and needs no change.

2. The genesis configuration proof

The CHANNEL_CONFIG validation branch for a channel that does not exist yet checked the parent and returned. It now verifies the proof against the threshold that a channel with no accredited key implies:

 else:
     # Channel will be created automatically upon execution
     # Ensure that this configuration is the genesis configuration
     assert config.parent == ZERO
+
+    # No key is accredited yet, so the threshold to verify against is 0
+    # and the proof must carry no signature and no index (see Appendix)
+    MultiEd25519_verify(txhash,
+                        proof.signatures,
+                        proof.indexes,
+                        [],
+                        0)

The routine is unchanged. Its first two assertions are what a threshold of 0 needs, one index per signature and exactly threshold signatures, so both lists must be empty, the ordering loop is vacuous and the verification loop is empty:

def MultiEd25519_verify(msg, signatures, indexes, keys, threshold):
    assert len(signatures) == len(indexes)
    assert len(signatures) == threshold
    # ... unchanged ordering and verification loops elided

The accredited key list passed is empty, which is the state of a channel before its creation, and no index can reach it since there is no index.

3. Execution Gas as a function of the Operation and the state

The fee computation takes the state the Operation is validated against, and passes it to the gas function:

 def mandatory_fees(signed_tx: SignedMantleTx,
+                   ledger: Ledger,
+                   channels: dict[ChannelId, ChannelState],
                    permanent_storage_gas_price: TokenValue, # Given by Storage Market
                    execution_gas_base_price: TokenValue) -> uint64:  # Given by Execution Market
     # ... unchanged permanent storage component elided
     for op in mantle_tx.ops:
-        tx_execution_gas += execution_gas(op)
+        tx_execution_gas += execution_gas(op, ledger, channels)

followed by the statement the reader needs when specifying a new Operation:

+The Execution Gas of an Operation is deterministically derived from that Operation and the state it is validated against.

The three Operations priced proportionally to a threshold now name where that threshold is read, which is the channel state and not the proof:

-`EXECUTION_CHANNEL_CONFIG_GAS * configuration_threshold`.
+`EXECUTION_CHANNEL_CONFIG_GAS * configuration_threshold`, where `configuration_threshold` is the one held in the channel state, and `0` for a channel that does not exist yet.

-`EXECUTION_CHANNEL_WITHDRAW_GAS * transfer_threshold`.
+`EXECUTION_CHANNEL_WITHDRAW_GAS * transfer_threshold`, where `transfer_threshold` is the one held in the channel state.

-`EXECUTION_CHANNEL_TRANSFER_GAS * transfer_threshold`.
+`EXECUTION_CHANNEL_TRANSFER_GAS * transfer_threshold`, where `transfer_threshold` is the one held in the channel state.

For CHANNEL_CONFIG this also resolves which of the two thresholds was meant. It is the one the channel holds, which is the number of signatures the Operation makes a validator verify, and not the one the payload proposes, which costs nothing to install. CHANNEL_WITHDRAW and CHANNEL_TRANSFER both require the channel to exist, so their threshold is always defined.

4. The channel threshold proof productions

Mantle Transaction Encoding listed one channel threshold proof out of three and encoded it without the signer indexes:

 OpProof   = Ed25519SigProof /
             ZkSigProof /
             ZkAndEd25519SigsProof /
+            ChannelConfigOpProof /
             ChannelWithdrawOpProof /
+            ChannelTransferOpProof /
             ProofOfClaimProof

 Ed25519SigProof         = Ed25519Signature
 ZkSigProof              = ZkSignature
 ZkAndEd25519SigsProof   = ZkSignature Ed25519Signature
-ChannelWithdrawOpProof  = SignatureCount *Ed25519Signature
+ChannelConfigOpProof    = ChannelMultiSigProof
+ChannelWithdrawOpProof  = ChannelMultiSigProof
+ChannelTransferOpProof  = ChannelMultiSigProof
 ProofOfClaimProof       = Groth16

+ChannelMultiSigProof = SignatureCount *IndexedSignature
+IndexedSignature     = Ed25519Signature SignerIndex
+
 SignatureCount = UINT16
+SignerIndex    = UINT16

The three proofs are the same structure in Mantle, a list of signatures with the index of the signing key for each, so they share one production. The signature precedes its index within an entry, and the entries are prefixed by their count, which makes a proof 2 + N * 66 bytes and an empty proof the two bytes encoding a count of 0.

Chores

  • A missing bracket in the CHANNEL_CONFIG example of Mantle, whose op_proofs did not nest the signature list and the index list into a single proof.
  • Terminology alignment in Gas Cost Determination, the two channel gas derivations naming the transfer_threshold that Mantle holds rather than the stake_manipulation_threshold it was called before the channel Proof of Stake update, and the Channel Stake Assignation section being the Channel Transfer one.
  • Revision history rows: 1.12.0 in Mantle, 1.7.0 in Mantle Transaction Encoding and 1.5.3 in Gas Cost Determination.

Implementation

  • Remove the None case from the operation proof list, every Operation carrying exactly one proof of the variant its opcode requires
  • Reject a CHANNEL_CONFIG on a channel that does not exist yet whose proof carries any signature or any index, by running the multi signature verification against an empty key list and a threshold of 0 instead of returning without inspecting the proof
  • Derive the Execution Gas of CHANNEL_CONFIG, CHANNEL_WITHDRAW and CHANNEL_TRANSFER from the threshold held in the channel state, 0 for a channel that does not exist yet, and never from the length of the signature list the proof carries
  • Confirm the pairing of Operations to proofs cannot silently truncate on a length mismatch, the equality of the two lengths being a validation rule
  • Add test vectors for the encoding of the three channel threshold proofs, including the empty proof of a channel creating configuration and its two byte encoding
  • Add a test that two bodies carrying the same Operations under the same state cannot be priced differently, covering the failure sequence of audit issue #136
  • Verify the implementation matches this specification

Affected Specifications

Specification Status Note
Mantle Modified version 1.12.0, the proof list, the CHANNEL_CONFIG validation and the Execution Gas of the three channel Operations
Mantle Transaction Encoding Modified version 1.7.0, the two missing OpProof variants and the ChannelMultiSigProof production
[Analysis] Gas Cost Determination Modified version 1.5.3, terminology only, the gas derivations naming a stake_manipulation_threshold that no longer exists
Bedrock Genesis Block Unmodified already requires one op_proofs entry per Operation of the type that Operation requires, consistent with the removal of the None case
Bedrock Block Construction Unmodified flagged for reviewer attention, it owns the body root and is where the audit's alternative recommendation, committing to the encoded signed Mantle Transaction, would land

Version numbers to watch when merging: Mantle is at 1.11.1 and Mantle Transaction Encoding at 1.6.1 on master, so both rows renumber if another PR lands first.

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.

1 participant