Skip to content

EIP-8141: canonical paymaster reference assembler and test suite - #12612

Draft
Marchhill wants to merge 6 commits into
eip8141-frame-txs-devnet7from
paymaster-stage2
Draft

EIP-8141: canonical paymaster reference assembler and test suite#12612
Marchhill wants to merge 6 commits into
eip8141-frame-txs-devnet7from
paymaster-stage2

Conversation

@Marchhill

Copy link
Copy Markdown
Contributor

Changes

  • Add paymaster_asm.py, a reproducible two-pass assembler for the EIP-8141 canonical paymaster. It emits the 355-byte runtime bytecode and its code hash 0xda42f0d11838c4c0c3129b8b8e93e9718127ad6b315e517e1088125707c4d45c.
  • Add Eip8141CanonicalPaymasterTests, a 22-test conformance suite exercising the canonical paymaster's payment authorization, deposit, withdrawal/rotation timelocks, admin routes (via caller and via signature), reentrancy protection, CALL-failure handling, static-validation rejection of the all-zero message, and non-payable finalize paths.

Reference spec: ethereum/EIPs#12012

Types of changes

What types of changes does your code introduce?

  • New feature (a non-breaking change that adds functionality)

Testing

Requires testing

  • Yes

If yes, did you write tests?

  • Yes

Notes on testing

22/22 tests in Eip8141CanonicalPaymasterTests pass. The assembler deterministically reproduces the 355-byte runtime and code hash.

Documentation

Requires documentation update

  • No

Requires explanation in Release Notes

  • No

Marchhill and others added 4 commits July 29, 2026 12:08
Two-pass label-resolving assembler for the canonical paymaster runtime,
plus a self-contained keccak-256 so the bytecode and its per-fork code
hash are reproducible. Mirrors the current PR #12012 semantics: the
validation scheme gate is scheme != ARBITRARY and admin ops authorize on
caller == slot0 OR a signer-signed entry at index 1.
Exercises the assembled canonical paymaster runtime end-to-end under the
prototype fork: sponsored EOA payment through the real bytecode, wrong/
missing/bespoke signature rejection, deposit rules, the timelocked
withdrawal and rotation lifecycles, cancel, non-signer/pending rejection,
a near-match code-hash fixture, and the measured validation-path gas.
Add coverage for the gaps flagged by the independent audit:
- F1: a 32-byte all-zero msg at index 1 is non-empty but passes the
  SIGPARAM(msg,1) != 0 value gate, so payment is approved when the
  signature resolves over the explicit zero digest to slot 0. Pins the
  current != 0 behavior (a strict len(msg)==0 fix would flip it).
- Withdrawal-finalize CALL failure: amount exceeds balance, and a signer
  contract that reverts on receive. Both revert the finalize and preserve
  the pending slots so it can be retried.
- Reentrancy: a contract signer re-entering finalize (cleared timelock ->
  revert) and initiate (fresh timelocked pending only). No double spend or
  drain; the signer is paid exactly once.
- Admin via the frame-tx signature route: a signer-signed entry at index 1
  authorizes an admin op; non-signer, ARBITRARY, wrong-index and non-empty
  msg entries do not.
- Non-payable finalize: op byte 0x04 with value routes to the deposit
  guard and reverts before op dispatch.

Fix SignEntries to finalize explicit-digest (non-empty msg) entries before
computing the sig hash, since their raw signature bytes are part of the
sig-hash preimage (only empty-msg entries are elided). Without this the
sender's canonical-hash signature went stale whenever an index carried a
non-empty msg.
F1 was derived from a stale ethereum/EIPs#12012 draft. The live spec
reserves the 32-byte all-zero digest as an invalid msg, and Nethermind
already rejects it in static validation (FrameTxValidation.ZeroDigestMsg),
so there is no live footgun: the value check and the empty-msg requirement
coincide exactly.

Rework AllZeroMsgAtIndex1_PassesValueGate_ApprovesPayment into
AllZeroMsgAtIndex1_RejectedByStaticValidation_ExecutionGateIsValueOnly,
which now pins both layers: (a) the real end-to-end defense — static
validation rejects the sponsored tx with ZeroDigestMsg before any frame
runs; and (b) defense-in-depth — the paymaster's SIGPARAM(msg,1) != 0 gate
is value-only, so a 32-byte zero digest would pass that gate if it ever
reached execution, which it never does in a full node. Drops the earlier
wording implying a strict len==0 fix would flip it to a vulnerability.
@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Marchhill's task in 8m 10s —— View job


Review: EIP-8141 canonical paymaster assembler + test suite

  • Gather context (diff, repo rules, EIP-8141 opcode surface)
  • Review paymaster_asm.py — hand-verified against the pinned bytecode
  • Review Eip8141CanonicalPaymasterTests.cs
  • Cross-check opcode semantics, bytecode, and length claims
  • Post review

Test-only, additive change (2 new files, no production code touched). The work is careful and the bytecode is correct — I verified it byte-for-byte. Findings are about enforcement and coverage, not correctness of what's here.

Verification performed

I hand-decoded PaymasterRuntimeHex and walked it against PROGRAM. Everything the PR claims checks out:

Claim Result
Two-pass label resolution is faithful ✅ all 11 jump targets match: HASVALUE=0x2e, ADMIN=0x35, FAIL=0x5a, W_INIT=0x5e, AUTH_OK_0=0x87, R_INIT=0xa5, AUTH_OK_1=0xce, CANCEL=0xec, AUTH_OK_2=0x115, FINALIZE=0x123, F_ROT=0x153
355-byte runtime F_ROT block ends at exactly 355
NearMatchMutationOffset = 158 is the PUSH3 0x015180 middle byte PUSH3 at 156, so 158 = 0x51
APPROVE operand order (offset, length, scope) EvmInstructions.FrameTx.cs:30
ApprovePayment = 0x1, not 0x2 TxFrame.cs:20
SIGPARAM order (signatureIndex, param); ARBITRARY = 0 EvmInstructions.FrameTx.cs:210,234
SIGPARAM(msg) is value-only for a 32-byte zero digest PushBytes of all-zero msg pushes 0 (:235) — test 12's rationale is sound, and FrameTxValidation.cs:158 does reject it statically
Scheme check precedes resolved_signer read ✅ important: param 0x00 on an ARBITRARY entry is BadInstruction, so ordering keeps test 17's Arbitrary case a revert rather than an exceptional halt
Hand-rolled Keccak-256 ✅ correct — 0x01/0x80 padding (incl. the rate-1 collision case), ρ/π offset matrix, χ masking of ~B, squeeze lane order (i%5, i//5), all 24 RCs

I also checked the stack discipline in FINALIZE (LT argument order, the DUP5 reach for value, CALL's 7 operands, SSTORE key/value order) and in BuildReentrantCaller (37-byte prelude, CODECOPY/CALL operand orders) — all correct. The checks-effects ordering that makes the reentrancy tests meaningful is genuinely there (slots 1 and 2 cleared before the value-bearing CALL).

I could not run python3 or dotnet test in this environment (both need approval), so the 22/22 claim is unverified here — but the hash pin is CI-enforced by the test itself.

Findings

Critical: none. High: none.

Medium

  1. Nothing enforces paymaster_asm.py output == PaymasterRuntimeHex (inline). The XML doc says they "must stay in sync", but the script isn't run by CI and doesn't self-check; the C# test only asserts keccak(hex) == pinned hash, which holds regardless of what the script emits. Edit PROGRAM, forget to re-paste → silent drift, and the script then prints a code hash that disagrees with the mempool recognition anchor. A 3-line assert in __main__ closes it. Fix this →

  2. No coverage of the underfunded paymaster (inline). InstructionApprove reverts when GetBalance(payer) < ctx.MaxCost (EvmInstructions.FrameTx.cs:61) — the branch that makes the entire deposit/withdrawal design meaningful. Every test that reaches APPROVE deploys with 1.Ether, so it never fires. Its mirror (deposit lifts the balance above MaxCost → same tx now valid) would also give test 5's deposit an end-to-end purpose.

  3. No coverage of the zero-argument initiate guards (inline). Both initiate paths carry an explicit DUP1 ISZERO JUMPI FAIL (paymaster_asm.py:94,106). Deleting both guards passes all 22 tests — yet RotationCall(Address.Zero) would brick the instance. Exactly what a conformance suite should pin. Unknown op bytes falling through to FAIL are likewise untested.

Low

  1. NonSigner and Recipient are the same accountTestItem.AddressC == PrivateKeyC.Address (TestItem.cs:74,81,97). No false pass today, but a latent trap. inline
  2. NearMatchMutationOffset = 158 is a magic index; derive it from the 62 01 51 80 sequence so it can't silently drift onto an unrelated byte while both assertions still pass. inline
  3. The canonical-hash pin (line 287) is buried in NearMatchBytecode_BehavesButCodeHashDiffers; a mismatch reports under a misdirecting test name. inline
  4. RevertOnCallCode's comment calls it "codeless-equivalent" — it's the opposite; it has code and reverts. inline
  5. Tests 13/14 differ only by setup → [TestCaseSource] per the DRY test rule; AssertFinalizeRevertsAndPreservesPending's finalizeTimestamp/unlockTime are always equal at both call sites. inline
  6. paymaster_asm.py lacks the SPDX header other tracked Python carries, and its docstring pins verification to branch eip8141-frame-txs-devnet7 — meaningless once merged. inline
  7. Dead 'jump' arms in encode/assemblePROGRAM has no ('jump', …) item. inline
  8. ValidationPathGas = 3_110 is an exact pin that any state-gas repricing breaks. Deliberate and documented, so fine — but the accompanying comment should say how to regenerate it.
  9. The fixture duplicates plumbing already in FrameTxProcessorTests.cs (identical Setup/TearDown/DeployContract, and the same v-r-s packing that file does inline at :520-522). A shared frame-tx test helper would serve both.

Notes, not findings

  • Test 12 asserts Success for a transaction a full node rejects. The extensive comment justifies it, and I agree the reasoning is right. Flagging only the maintenance shape: if validation is ever hardened inside the processor, this test fails and reads as a regression when it isn't.
  • I looked for a signature-reuse attack in authorized() — an empty-msg index-1 entry authorizes admin ops in any frame of the transaction, including the same entry that sponsors payment. It's safe: the sig hash commits to the full frame list, so the signer consents to the admin calldata. Test 16 documents this deliberately.
  • The pay frame reverts if placed before the sender's execution approval (:60) — untested, low value.
  • No hot-path or performance impact; nothing outside the test project changes.

Verdict

Not blocking on correctness — the assembler and the bytecode are right, and I checked them independently rather than taking the PR's word for it. Worth landing after the three Medium items: the drift guard is a few lines, and the two coverage gaps are branches the canonical bytecode explicitly implements but nothing exercises.
• branch paymaster-stage2

Comment on lines +238 to +242
if __name__ == '__main__':
code = assemble()
print("length:", len(code), "bytes")
print("bytecode:", "0x" + code.hex())
print("codehash:", "0x" + keccak256(code).hex())

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.

Medium — nothing enforces script ↔ constant agreement.

Eip8141CanonicalPaymasterTests documents that PaymasterRuntimeHex/CanonicalCodeHash "must stay in sync with that script", but nothing checks it: the script is not run by CI, and the C# test only asserts keccak(hex) == pinned hash — which holds no matter what the script emits. If someone edits PROGRAM and forgets to re-paste the hex, the drift is silent, and the script then prints a code hash that disagrees with the mempool recognition anchor.

Cheapest fix: make the script self-checking so running it fails loudly.

Suggested change
if __name__ == '__main__':
code = assemble()
print("length:", len(code), "bytes")
print("bytecode:", "0x" + code.hex())
print("codehash:", "0x" + keccak256(code).hex())
EXPECTED_LENGTH = 355
EXPECTED_CODEHASH = '0xda42f0d11838c4c0c3129b8b8e93e9718127ad6b315e517e1088125707c4d45c'
if __name__ == '__main__':
code = assemble()
codehash = '0x' + keccak256(code).hex()
assert len(code) == EXPECTED_LENGTH, f'length drifted: {len(code)} != {EXPECTED_LENGTH}'
assert codehash == EXPECTED_CODEHASH, f'code hash drifted: {codehash}'
print("length:", len(code), "bytes")
print("bytecode:", "0x" + code.hex())
print("codehash:", codehash)

Stronger alternative worth considering: build the runtime in C# (the repo already has Prepare.EvmCode, used in FrameTxProcessorTests) so CI enforces assembly → hash on every run and the Python drops out entirely.

Comment on lines +82 to +83
private static readonly TxFrame PayFrame =
new(TxFrame.ModeVerify, TxFrame.ApprovePayment, Paymaster, gasLimit: 15_000, UInt256.Zero, default);

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.

Medium — missing coverage: underfunded paymaster.

InstructionApprove reverts the pay frame when the payer cannot cover the transaction:

// EvmInstructions.FrameTx.cs:61
if (vm.WorldState.GetBalance(resolvedTarget) < ctx.MaxCost) return EvmExceptionType.Revert;

That is the most security-relevant branch for a paymaster specifically — the whole point of the deposit/withdrawal machinery is that the balance backs the sponsorship — and none of the 22 tests exercises it. Every test that reaches APPROVE deploys with 1.Ether.

Suggest adding a case that deploys the paymaster with a balance below MaxCost (sum of frame gas limits × 1 wei here) and asserts the sponsored tx is invalid, plus its mirror: a deposit that brings the balance above MaxCost makes the same tx valid. That also gives test 5's deposit path an end-to-end purpose.

Comment on lines +271 to +280
TxReceipt fromNonSigner = ProcessBlock(BlockTimestamp, AdminTx(NonSignerKey, 0, WithdrawalCall(500)))[0];
Assert.That(fromNonSigner.StatusCode, Is.EqualTo(StatusCode.Failure), "a non-signer cannot initiate");
Assert.That(Slot(2), Is.EqualTo(UInt256.Zero), "a rejected initiate creates no pending action");

ProcessBlock(BlockTimestamp, AdminTx(SignerKey, 0, WithdrawalCall(500)));
TxReceipt whilePending = ProcessBlock(BlockTimestamp, AdminTx(SignerKey, 1, RotationCall(NewSigner)))[0];
Assert.That(whilePending.StatusCode, Is.EqualTo(StatusCode.Failure), "a second action while one is pending is rejected");
Assert.That(Slot(3), Is.EqualTo(UInt256.Zero), "the blocked rotation writes nothing");
Assert.That(Slot(1), Is.EqualTo((UInt256)500), "the pending withdrawal is untouched");
}

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.

Medium — missing coverage: the zero-argument initiate guards.

Both initiate paths contain an explicit DUP1 ISZERO JUMPI FAIL guard (bytecode offsets 146–151 for W_INIT, and the mirror in R_INIT; paymaster_asm.py:94 and :106), rejecting amount == 0 and new_signer == 0. Neither is covered, so a mutation that deletes both guards passes all 22 tests — a rotation to address(0) would brick the instance, which is exactly the kind of thing a conformance suite should pin.

Since this test already covers the other two initiate rejections, extending it (or adding two [TestCase]s) is cheap:

ProcessBlock(BlockTimestamp, AdminTx(SignerKey, n, WithdrawalCall(0)))     // expect Failure, Slot(2) == 0
ProcessBlock(BlockTimestamp, AdminTx(SignerKey, n, RotationCall(Address.Zero))) // expect Failure, Slot(3) == 0

While there: an unrecognised op byte (e.g. 0x05) falling through to FAIL is also untested.

Comment on lines +70 to +71
private static readonly Address NonSigner = TestItem.PrivateKeyC.Address;
private static readonly PrivateKey NonSignerKey = TestItem.PrivateKeyC;

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.

Low — NonSigner and Recipient are the same account.

TestItem.AddressC is PublicKeyC.Address, i.e. TestItem.PrivateKeyC.Address (TestItem.cs:74,81,97). So NonSigner == Recipient (line 66), even though they read as two distinct actors: the action-frame target and the unauthorized caller.

No test currently produces a false pass from this, but it is a latent trap — Fund(NonSigner) in AdminInitiate_RejectsNonSignerAndPendingAction also funds the action-frame recipient, and any future balance assertion on Recipient would be polluted by non-signer activity. Prefer an unrelated key (e.g. TestItem.PrivateKeyG) for NonSigner.

Comment on lines +48 to +50
// Middle byte of the DELAY constant (PUSH3 0x015180) in the withdrawal-initiate path — a mutation
// here changes the code hash without touching the validation path.
private const int NearMatchMutationOffset = 158;

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.

Low — derive the mutation offset instead of hard-coding it.

I verified 158 is correct today: the set_unlock() PUSH3 0x015180 sits at offsets 156–159, so 158 is the 0x51 middle byte. But this is a magic index into a 355-byte blob — any change to the program above it silently moves it, and the test would then mutate some unrelated byte while still passing (both assertions stay true almost anywhere), quietly losing the "keeps behaving, hash differs" property it is meant to pin.

Locating the 62 01 51 80 sequence at runtime makes it self-maintaining:

// Middle byte of the DELAY constant (PUSH3 0x015180) in the withdrawal-initiate path — a mutation
// here changes the code hash without touching the validation path.
private static readonly int NearMatchMutationOffset =
    PaymasterCode.AsSpan().IndexOf<byte>([0x62, 0x01, 0x51, 0x80]) + 2;

(PaymasterCode is declared below, so it needs to move above this field.)

Comment on lines +76 to +78
// A codeless-equivalent contract that unconditionally reverts on any call (PUSH0 PUSH0 REVERT),
// used as a withdrawal target that rejects the incoming value.
private static readonly byte[] RevertOnCallCode = [0x5f, 0x5f, 0xfd];

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.

Low — the comment contradicts the code. This is not "codeless-equivalent"; it is a contract with code (PUSH0 PUSH0 REVERT) whose whole purpose is to be reached and to revert — the opposite of codeless (a codeless target would accept the value).

Suggested change
// A codeless-equivalent contract that unconditionally reverts on any call (PUSH0 PUSH0 REVERT),
// used as a withdrawal target that rejects the incoming value.
private static readonly byte[] RevertOnCallCode = [0x5f, 0x5f, 0xfd];
// A contract that unconditionally reverts on any call (PUSH0 PUSH0 REVERT), used as a
// withdrawal target that rejects the incoming value.
private static readonly byte[] RevertOnCallCode = [0x5f, 0x5f, 0xfd];

Comment on lines +1 to +15
"""Canonical paymaster runtime assembler - EIP-8141 (Stage 2, finalized).

Opcode stack orders verified against Nethermind.Evm/Instructions/EvmInstructions.FrameTx.cs
on branch eip8141-frame-txs-devnet7:

APPROVE (0xaa): pops (offset, length, scope) top-to-bottom -> push scope, length, offset.
ApprovePayment scope bit = 0x1 (TxFrame.ApprovePayment), NOT 0x2.
SIGPARAM (0xb4): pops (signatureIndex, param) top-to-bottom -> push param, then index.
param 0x00 resolved_signer, 0x01 scheme, 0x02 msg. Scheme ARBITRARY = 0x0.

Spec (ethereum/EIPs #12012, 2026-07-24 update mirrored):
- validation scheme gate is `scheme != ARBITRARY` (no named curve).
- admin authorized() = CALLER == slot0 OR a signer-signed entry at index 1
(scheme != ARBITRARY, resolved_signer == slot0, empty msg).
"""

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.

Low — two doc/convention nits.

  1. Missing SPDX header. Tracked Python in this repo carries one (scripts/superchain.py, scripts/evm_benchmark_utils.py):
    # SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited
    # SPDX-License-Identifier: LGPL-3.0-only
  2. The docstring pins the verification against "branch eip8141-frame-txs-devnet7". Per AGENTS.md, comments should read correctly against master, where that branch name is meaningless. Citing the file (EvmInstructions.FrameTx.cs) is enough — drop the branch.

Comment on lines +149 to +180
def encode(item):
kind = item[0]
if kind == 'op':
return bytes([OPS[item[1]]])
if kind == 'push':
return push_bytes(item[1])
if kind in ('jumpi', 'jump'):
return bytes([0x61, 0, 0]) + bytes([OPS['JUMPI'] if kind == 'jumpi' else OPS['JUMP']]) # PUSH2 <hi><lo> + JUMP(I)
if kind == 'dest':
return bytes([OPS['JUMPDEST']])
raise ValueError(item)


def assemble():
# Pass 1: assign offsets, collect label addresses (all label refs are fixed-width PUSH2).
labels = {}
offset = 0
for item in PROGRAM:
if item[0] == 'dest':
labels[item[1]] = offset
offset += len(encode(item))

# Pass 2: emit, patching PUSH2 operands with resolved addresses.
out = bytearray()
for item in PROGRAM:
if item[0] in ('jumpi', 'jump'):
addr = labels[item[1]]
op = OPS['JUMPI'] if item[0] == 'jumpi' else OPS['JUMP']
out += bytes([0x61, (addr >> 8) & 0xff, addr & 0xff, op])
else:
out += encode(item)
return bytes(out)

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.

Low — dead jump support. PROGRAM contains no ('jump', …) item, so the 'jump' arms in encode (line 155) and assemble (line 174) plus OPS['JUMP'] are unreachable. AGENTS.md asks for unused code to be removed; dropping them makes both passes read as "every label ref is a fixed-width PUSH2 + JUMPI", which is the actual invariant the two-pass scheme relies on.

Comment on lines +285 to +293
public void NearMatchBytecode_BehavesButCodeHashDiffers()
{
Assert.That(Keccak.Compute(PaymasterCode), Is.EqualTo(new Hash256(CanonicalCodeHash)),
"the assembled bytecode must hash to the pinned canonical code hash");

byte[] nearMatch = (byte[])PaymasterCode.Clone();
nearMatch[NearMatchMutationOffset] ^= 0x01;
Assert.That(Keccak.Compute(nearMatch), Is.Not.EqualTo(new Hash256(CanonicalCodeHash)),
"a one-opcode mutation must fail exact-hash recognition");

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.

Low — split the canonical-hash pin into its own test.

Line 287 is the single most important assertion in the file (the mempool recognition anchor), but it is buried inside a test about near-match bytecode. If the assembler output ever changes, the failure surfaces as NearMatchBytecode_BehavesButCodeHashDiffers — a name that actively misdirects. A standalone AssembledBytecode_MatchesCanonicalCodeHash would fail with the right story, and would keep failing independently of whatever the near-match case does.

Comment on lines +356 to +379
[Test]
public void MaturedFinalize_AmountExceedsBalance_RevertsAndPreservesPending()
{
Fund(ThirdParty);
DeployPaymaster(PaymasterCode, Signer, 1.Ether);
UInt256 amount = 2.Ether;
SetPending(amount, BlockTimestamp);

AssertFinalizeRevertsAndPreservesPending(BlockTimestamp, amount, BlockTimestamp);
}

// 14. Same revert-and-preserve guarantee when the signer is a contract that reverts on receiving
// the withdrawal value: the CALL returns failure and the finalize rolls back its slot clearing.
[Test]
public void MaturedFinalize_SignerRejectsValue_RevertsAndPreservesPending()
{
Fund(ThirdParty);
DeployContract(RevertingReceiver, RevertOnCallCode, UInt256.Zero);
DeployPaymaster(PaymasterCode, RevertingReceiver, 1.Ether);
UInt256 amount = 100;
SetPending(amount, BlockTimestamp);

AssertFinalizeRevertsAndPreservesPending(BlockTimestamp, amount, BlockTimestamp);
}

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.

Low — these two differ only by setup; parameterize per the DRY test rule.

AGENTS.md: "When tests differ only by inputs and expected outputs, parameterize a single test with [TestCase(...)] or [TestCaseSource(...)]." Tests 13 and 14 already share the assertion helper; only the deploy/amount differ, so a [TestCaseSource] (as done for ReentrancyCases) collapses them.

Also, AssertFinalizeRevertsAndPreservesPending(ulong finalizeTimestamp, UInt256 amount, ulong unlockTime) receives the same value for finalizeTimestamp and unlockTime at both call sites, so that parameterization is currently unused surface — one ulong timestamp would express the intent ("finalize exactly at maturity") more directly.

- assembler self-checks output length and code hash so PROGRAM edits
  fail loudly instead of drifting from the pinned constants
- cover the underfunded-paymaster solvency gate and its deposit-backed
  mirror, giving the deposit path an end-to-end purpose
- fold the two matured-finalize revert cases into a TestCaseSource and
  drop the always-equal timestamp parameter
@Marchhill

Copy link
Copy Markdown
Contributor Author

Addressed the three findings in a188ebd:

  • Assembler drift guardpaymaster_asm.py now pins EXPECTED_LENGTH = 355 and EXPECTED_CODEHASH and asserts both in __main__, so editing PROGRAM without re-pasting the hex fails loudly instead of drifting from the C# constants. Verified the assembler still emits exactly 355 bytes / 0xda42f0d1…c4d45c.
  • Underfunded-paymaster coverage — added a test where the instance is deployed below MaxCost so the pay frame's solvency gate (InstructionApprove) reverts and the tx is invalid, plus its mirror where a plain-value deposit lifts the balance above MaxCost and the identical sponsored tx becomes valid — giving the deposit path an end-to-end purpose.
  • DRY — folded the two matured-finalize revert cases (13/14) into a [TestCaseSource], and simplified AssertFinalizeRevertsAndPreservesPending to a single timestamp parameter (the old finalizeTimestamp/unlockTime were always equal).

Build clean; Eip8141CanonicalPaymasterTests 24/24 green.

@Marchhill

Copy link
Copy Markdown
Contributor Author

@claude please re-review the latest changes (fixes at a188ebd).

@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Marchhill's task in 4m 13s —— View job


Re-review: a188ebd

  • Read the delta vs the previous review
  • Verify each of the three Medium items is actually closed
  • Check the new tests are sound (not vacuously passing)
  • Post updated review

The commit is a clean, focused follow-up — still test-only, no production code touched. Two of the three prior Mediums are properly closed, and the DRY fold is good. One Medium from the previous review is not addressed and not acknowledged, and the new underfunded test doesn't pin the branch it targets.

Status of prior findings

# Prior finding Sev Status
1 Nothing enforces script ↔ constant agreement Medium Closed. EXPECTED_LENGTH/EXPECTED_CODEHASH asserted in __main__; EXPECTED_CODEHASH is byte-identical to CanonicalCodeHash. One residual window remains — see inline (Low, follow-up).
2 No coverage of the underfunded paymaster Medium ⚠️ Partially closed. Tests 19/20 are the right pair, but 19's assertion doesn't distinguish the APPROVE gate from the charge-time gate — see below.
3 No coverage of the zero-argument initiate guards Medium Still open, no rationale given.
8 Tests 13/14 differ only by setup Low Closed. [TestCaseSource(nameof(FinalizeRevertCases))], and AssertFinalizeRevertsAndPreservesPending down to one timestamp. Correctly gated: the RevertingReceiver deploy only happens for the case that needs it.
4, 5, 6, 7, 9, 10 NonSigner == Recipient; magic offset 158; hash pin buried in the near-match test; "codeless-equivalent" comment; SPDX + branch pin in the docstring; dead 'jump' arms Low Unchanged — all still present. Fine to leave; none is a correctness risk.

New findings

Medium

  1. UnderfundedPaymaster_PayFrameReverts_TxInvalid passes with the gate it claims to pin removed (inline). There are two independent MaxCost gates: EvmInstructions.FrameTx.cs:61 reverts the frame, and TransactionProcessorBase.FrameTx.cs:518 silently declines to set Payer. TransactionExecuted is False is true under both. Delete :61 and the pay frame succeeds, :518 bails, Payer stays null, the tx fails with "frame transaction never set a payer" — same observable, test still green. Asserting result.ErrorDescription == "VERIFY frame reverted" (:210 vs :270 carry distinct strings) makes it discriminating. Cheap, and it's the difference between the fix landing and appearing to land. Fix this →

  2. Prior Medium Netcore #3 is still open. The PR comment says "addressed the three findings", but the third item listed was the DRY fold — a Low. The zero-argument initiate guards (paymaster_asm.py:94,106DUP1 ISZERO JUMPI FAIL in both W_INIT and R_INIT) remain uncovered: deleting both still passes the suite, and RotationCall(Address.Zero) bricks the instance. Two [TestCase]s on the existing AdminInitiate_RejectsNonSignerAndPendingAction close it. Happy to treat it as deliberately deferred if you'd rather scope it out — just say so and I'll drop it. Fix this →

Low

  1. MaxCost is not the frame-gas sum (inline). maxCost = (intrinsic + Σ frame.gas_limit) × maxFeePerGas (TransactionProcessorBase.FrameTx.cs:68,82), so the threshold is ~350k, not the 315,000 stated at lines 55–56 and 511. No behavioural impact (1,000 ≪, 1,001,000 ≫), but a reader sizing a deposit at 320,000 from that comment would be wrong.
  2. Residual drift window in the Python pin — inline. Follow-up, not this PR.
  3. Nit: MaxturedFinalize_FailedWithdrawalCall_... takes a bool signerRejectsValue that drives control flow (? RevertingReceiver : Signer plus a conditional deploy). Passing the signer address (or null) as the case parameter would read as data rather than a flag. Marginal — the current form is clear enough.

What I verified

  • Diff read in full; only the two test-project files change, nothing outside Nethermind.Evm.Test. No hot-path or consensus impact.
  • EXPECTED_CODEHASH (Python) is character-for-character equal to CanonicalCodeHash (C#), and PaymasterRuntimeHex is untouched by this commit — so the byte-level verification from the previous review still stands.
  • Both MaxCost gates read from source, plus the two failure paths (:210, :270) that make finding 1 concrete, and the maxCost formula behind finding 3.
  • Test 20's arithmetic: the deposit lands on top of UnderfundedBalance, and 1,001,000 clears the real threshold with margin.

I could not run python3 or dotnet build/dotnet test here (both need approval), so the 24/24 green and "assembler still emits 355 bytes / 0xda42f0d1…c4d45c" claims are from your run, not reproduced by me. The Python asserts are textually what was suggested, and CI runs the C# fixture.

Verdict

Good iteration. Not blocking on correctness — nothing here is wrong. Blocking as not-yet-mergeable only on the two Mediums above, both small: one assertion line in test 19, and two [TestCase]s for the zero-argument guards (or an explicit "deferred" from you).
• branch paymaster-stage2

Comment on lines +496 to +498
TransactionResult result = ExecuteInvalid(tx, BlockTimestamp);

Assert.That(result.TransactionExecuted, Is.False);

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.

Medium — this test passes even with the gate it claims to pin removed.

There are two independent solvency gates against MaxCost:

// EvmInstructions.FrameTx.cs:61 — reverts the frame
if (vm.WorldState.GetBalance(resolvedTarget) < ctx.MaxCost) return EvmExceptionType.Revert;

// TransactionProcessorBase.FrameTx.cs:518 — silently declines to set Payer
if (WorldState.GetBalance(resolvedTarget) < frameContext.MaxCost) return;

TransactionExecuted is False cannot tell them apart. Delete the APPROVE gate at :61 and the pay frame succeeds, ApplyPaymentApproval then bails at :518, Payer stays null, and the tx fails with "frame transaction never set a payer" — still TransactionExecuted == False, so this test stays green. The comment promises "APPROVE reverts when the instance holds less than MaxCost", but nothing here pins that.

The two paths carry distinct ErrorDescriptions (:210 vs :270), so one extra assertion makes the test actually discriminating:

Suggested change
TransactionResult result = ExecuteInvalid(tx, BlockTimestamp);
Assert.That(result.TransactionExecuted, Is.False);
TransactionResult result = ExecuteInvalid(tx, BlockTimestamp);
Assert.That(result.TransactionExecuted, Is.False);
Assert.That(result.ErrorDescription, Is.EqualTo("VERIFY frame reverted"),
"the APPROVE solvency gate reverts the pay frame, rather than the charge-time gate silently leaving the payer unset");

Comment on lines +55 to +57
// Below MaxCost for the sponsored tx (frame gas limits sum to 315,000 at 1 wei/gas), so an
// instance deployed with this balance cannot back the sponsorship until a deposit tops it up.
private static readonly UInt256 UnderfundedBalance = 1_000;

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.

Low — MaxCost is not the frame-gas sum.

TransactionProcessorBase.FrameTx.cs:68,82:

ulong txGasLimit = intrinsicGas + totalFrameGas;
UInt256 maxCost = (UInt256)txGasLimit * tx.DecodedMaxFeePerGas + ...;

So MaxCost == (intrinsic + 315,000) × 1, not 315,000 — intrinsic here is the 21,000 base plus per-frame, calldata and signature-verification components. Both this comment and the one at line 511 ("clears the 315,000-wei sum of the frame gas limits") state the frame-gas sum as the threshold. Harmless today (1,000 is far below, 1,001,000 far above), but it misstates the bound a future reader would size a deposit against — 320,000 would look sufficient and would not be. Suggest wording the threshold as intrinsic + Σ frame.gas_limit at 1 wei/gas in both places.

Comment on lines +238 to +241
# Pinned output. Must equal PaymasterRuntimeHex/CanonicalCodeHash in Eip8141CanonicalPaymasterTests.cs;
# asserted below so editing PROGRAM without re-pasting the hex fails loudly instead of drifting silently.
EXPECTED_LENGTH = 355
EXPECTED_CODEHASH = '0xda42f0d11838c4c0c3129b8b8e93e9718127ad6b315e517e1088125707c4d45c'

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.

The drift guard does what was asked, and I confirmed EXPECTED_CODEHASH is byte-identical to CanonicalCodeHash in the C# fixture. Low, residual: it closes the forgot-to-re-paste window but not the updated-both-Python-constants one — edit PROGRAM, update EXPECTED_LENGTH/EXPECTED_CODEHASH, and the C# PaymasterRuntimeHex/CanonicalCodeHash pair remains self-consistent on the old bytecode, so both sides pass while describing different contracts. Nothing to do now (the comment on line 238 names the C# constants, which is the right pointer for a human), but the only structural close is still assembling in C# so one artifact produces both. Worth a follow-up issue rather than more Python.

Add a parameterized case that a zero-amount withdrawal, a rotation to
address(0), and an unrecognised op byte all revert without opening a
pending action. Tighten the underfunded-paymaster test to assert the
APPROVE balance gate reverts the pay frame ("VERIFY frame reverted"),
distinguishing it from the charge-time gate.
@Marchhill

Copy link
Copy Markdown
Contributor Author

Addressed the residual re-review items in 7692928:

  • Finding 3 (zero-argument initiate guards) — closed. Added AdminInitiate_RejectsZeroArgumentAndUnknownOp, a [TestCaseSource] covering the three FAIL paths that were unmodeled: an initiate-withdrawal of amount 0 (DUP1 ISZERO JUMPI FAIL), an initiate-rotation to address(0) (its mirror guard, which would otherwise brick the instance), and an unrecognised op byte (0x05) falling through dispatch. Each asserts StatusCode.Failure and that slots 1/2/3 stay clear. A mutation deleting either guard now fails.

  • Finding 2 (test 19 gate discrimination) — closed. UnderfundedPaymaster_PayFrameReverts_TxInvalid now asserts result.ErrorDescription == "VERIFY frame reverted", pinning that it is APPROVE's GetBalance(payer) < MaxCost check (EvmInstructions.FrameTx.cs) reverting the pay frame — not the charge-time gate (TransactionProcessorBase.FrameTx.cs), which would instead leave the payer unset and fail with "frame transaction never set a payer". Deleting the APPROVE gate now flips the error description and fails the test.

  • Low follow-up (assembler residual window) — deferred, per the review's own conclusion. The drift guard already closes the forgot-to-re-paste case; the remaining window (edit PROGRAM + both Python constants, leaving the C# pair self-consistent on stale bytecode) has no clean in-file close. Invoking the script or reading the source file from the unit test would add a Python/file-IO runtime dependency to the .NET test process that is not guaranteed on CI runners; the only robust structural fix is porting the assembler to C# so a single artifact emits both, which is a larger change out of scope for this hardening pass. The comment at paymaster_asm.py:238 already names the C# constants as the human pointer.

Build: 0 errors/0 warnings. Eip8141CanonicalPaymasterTests: 27/27 green. dotnet format whitespace: clean.

@Marchhill

Copy link
Copy Markdown
Contributor Author

@claude please re-review the latest changes (fixes at 7692928).

@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Marchhill's task in 3m 6s —— View job


Re-review: 7692928

  • Read the delta vs a188ebd
  • Verify the two open Mediums are closed
  • Check the new tests aren't vacuously passing
  • Post updated review

Both remaining Mediums are properly closed, and I verified the new cases actually discriminate rather than passing by construction. No Critical, High, or Medium findings remain.

Status of prior findings

# Prior finding Sev Status
Re-review 1 Test 19 passes with the gate it pins removed Medium Closed.
Re-review 2 / first-review 3 Zero-argument initiate guards uncovered Medium Closed.
Re-review 4 Residual assembler drift window Low Deferred with rationale — accepted, matches my own conclusion (the only structural close is porting the assembler to C#).
Re-review 3 MaxCost is not the frame-gas sum Low ❌ Still open — see below.
First review 4, 5, 6, 7, 9, 10 NonSigner == Recipient; magic offset 158; hash pin buried in the near-match test; "codeless-equivalent" comment; SPDX + branch pin in the docstring; dead 'jump' arms Low Unchanged. None is a correctness risk.

Verification of the new coverage

PaymasterRuntimeHex is byte-identical to the previous commit, so I re-decoded the relevant regions from the pinned hex directly rather than trusting the Python:

  • Dispatch (5f3560f81c 8060011461005e57 8060021461 00a557 806003146100ec57 60041461012357 5b5f5ffd) — three DUP1-guarded comparisons for ops 1/2/3, then a consuming EQ for op 4, falling straight through into the FAIL JUMPDEST at 0x5a. Case 3 (0x05) reaches FAIL by fallthrough as claimed. CALLDATASIZE == 1 is nonzero, so the 1-byte calldata routes to ADMIN, and CALLDATALOAD(0) zero-pads — the op byte reads as 0x05.
  • W_INIT guard (6001 35 80 15 61005a57) — PUSH1 1 / CALLDATALOAD / DUP1 / ISZERO / JUMPI FAIL. Present and reached. R_INIT mirrors it with 600355.
  • Mutation-discrimination, case by case: deleting either guard makes the initiate succeed, writing slot2 = TIMESTAMP + DELAY. Both StatusCode.Failure and Slot(2) == 0 flip. Confirmed the tests are not vacuous.

For test 19: TransactionResult.ErrorDescription is the raw WithDetail string, not a composed one (TransactionProcessor.cs:1829,1835), and "VERIFY frame reverted" occurs exactly once in the tree (TransactionProcessorBase.FrameTx.cs:210), distinct from "frame transaction never set a payer" (:270). With UnderfundedBalance = 1_000 < MaxCost, EvmInstructions.FrameTx.cs:61 reverts the VERIFY frame and :210 produces that exact string. Deleting :61 yields the other string and the test fails. The assertion does what it says.

Findings

Critical: none. High: none. Medium: none.

Low (carried over, unaddressed)

  1. MaxCost still described as the frame-gas sum — lines 55–56 (frame gas limits sum to 315,000 at 1 wei/gas) and 516 (clears the 315,000-wei sum of the frame gas limits). maxCost = (intrinsic + Σ frame.gas_limit) × maxFeePerGas, so the real threshold is ~350k. No behavioural impact (1,000 ≪, 1,001,000 ≫), but a reader sizing a deposit at 320,000 from these comments would be wrong. Two-word fix; flagging that it was neither changed nor mentioned. Fix this →

Notes, not findings

  • InitiateGuardRejectionCases is named for the two guards but also carries the unknown-op fallthrough case, which is not a guard. Cosmetic.
  • In the rotation case, Slot(3) == 0 is not itself discriminating (a removed guard would store address(0) into slot 3, i.e. still zero). StatusCode.Failure and Slot(2) == 0 are what catch it — both present, so the case is sound. Worth knowing if anyone later prunes assertions.
  • The "VERIFY frame reverted" literal is now duplicated between production and test, so a reword breaks the test. Extracting a const would need a production edit, which is outside this test-only PR's scope — fine as-is.

What I could and could not run

Diff read in full; only the two test-project files change vs the merge base (7dd8c76), nothing outside Nethermind.Evm.Test. No hot-path, consensus, or DI impact. paymaster_asm.py is untouched by this commit, so the byte-level assembler verification from the first review still stands.

python3 and dotnet build/dotnet test both require approval in this environment, so the 27/27 green and 0 errors/0 warnings claims are yours, not reproduced here. The C# fixture is CI-enforced; the Python drift asserts are not, by design.

Verdict

Mergeable. Both Mediums are genuinely closed — I checked the mutations, not just the presence of the tests. The one remaining Low is a comment wording issue; land it whenever convenient.
• branch paymaster-stage2

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant