Skip to content

Multisig implementation - #480

Draft
axic wants to merge 81 commits into
argotorg:mainfrom
axic:multisig
Draft

Multisig implementation#480
axic wants to merge 81 commits into
argotorg:mainfrom
axic:multisig

Conversation

@axic

@axic axic commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Disclaimer: this is work in progress, for discussion only and not review.

There are three blockers currently:

  1. Blocks the entire work:
  • Storage encoding for ADTs
  1. Blocks the off-chain version (where instructions are signed outside):
  • Ability to manually hash them, that's the keccak256(concat(abi_encode(kind), abi_encode(operation))) line
  1. Blocks optional batching:
  • ABI encoding of array(t)

@axic axic changed the title Multisg implementation Multisig implementation Jun 19, 2026
@axic
axic force-pushed the multisig branch 5 times, most recently from 2abe47b to 7d6625e Compare June 25, 2026 14:05
claude and others added 22 commits July 24, 2026 11:49
Adds decoding of `array(T)` method parameters across the ABI boundary,
including arrays whose element is a sum-typed ADT (e.g.
`Operation = Approve(uint256) | Reject(uint256)`), which the word-per-slot
`memory(DynArray(...))` representation cannot hold.

The chosen representation is calldata + lazy decode: a
`calldata(array(T))` parameter decodes to a calldata handle pointing at the
array's length word (following the ABI head offset), and elements stay in
calldata, decoded on demand via `abiArrayLength` / `abiArrayGet`. Nothing is
materialised at decode time, so any decodable element type works — including
multi-word ADTs.

- std/std.solc: `array(t):ABIAttribs`; a `calldata(array(baseType))`
  `ABIDecode` instance that follows the head offset to the length word; and
  the `abiArrayLength` / `abiArrayGet` accessors (exported).
- std/dispatch.solc: `calldata(array(t)):SigString` -> `<element>[]`, so an
  array parameter produces a selector (`sum(l,r)[]` for an ADT element).
- test/examples/dispatch/abi_array_sum.{solc,json}: a `Batch` contract taking
  `calldata(array(Operation))` and reading length / tag / payload of elements;
  registered in run_contests.sh and test/Cases.hs.

Co-Authored-By: Alex Beregszaszi <alex@rtfs.hu>
Add an RValueIdxAccess instance for calldata(array(t)) so a lazily-decoded
calldata array supports the ordinary `arr[i]` read sugar, matching how storage
arrays and mappings are indexed. `arr[i]` desugars to ridx(arr, i), which now
dispatches here and decodes element i on demand via abiArrayGet.

Deliberately no LValueIdxAccess instance: calldata is immutable, so `arr[i] = …`
stays a compile error.

Updates the abi_array_sum dispatch test to index with ops[i] instead of the
explicit abiArrayGet call. (Length still uses abiArrayLength; the `.length()`
sugar is handled on a separate branch.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012JtxyDTDRRyoqWsXaRNp8H
tagOf returned 0/1, which coincide with the on-wire sum tag (inl=0, inr=1),
so the test could pass even if it echoed the raw tag word instead of
discriminating the constructor through the match. Return 16 for Approve and
32 for Reject so distinct, non-trivial values prove the match actually maps
each constructor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012JtxyDTDRRyoqWsXaRNp8H
abiArrayGet decoded element i without validating i against the array length,
so an out-of-range index would read past the encoded elements. Add a
require(i < abiArrayLength(a)) guard that reverts with ArrayOutOfBounds()
(selector 0x7f52b2bf). Uses `<` since indices are 0-based (valid range
[0, length)), matching the storage-array out-of-bounds guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012JtxyDTDRRyoqWsXaRNp8H
A complex three-level ADT decoded from a calldata dynamic array, exercising
the derived ABIDecode across nested sums and products:

  Operation : sum(address, address)
  Signature : sum((bytes32, bytes32), address)
  Batch     : sum((Operation, Signature), (uint256, memory(bytes)))

queueSigner extracts the AddSigner address + Contract address out of a
Batch.Queue; execPayload extracts the memory(bytes) out of a Batch.Execute.

Registered via runDispatchTest, which compiles the contract through the
pipeline. It is compile-only: the Queue path is a fully static nested
sum-of-product handled by the fixed-width element codec, but Batch is dynamic
(Execute carries memory(bytes)), and materialising a dynamic branch out of a
fixed-width inline array element is beyond the current "static sums only"
codec — so there is no runtime calldata fixture yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012JtxyDTDRRyoqWsXaRNp8H
sum(f,g):ABIAttribs.headSize always returned 32 + max(branch heads), i.e. the
full inline size, even for a dynamic sum. In standard ABI a dynamic value
occupies a single 32-byte offset word in the head (its payload lives in the
tail), so a dynamic sum's head footprint is 32; only a fully static sum is laid
out inline as tag + widest branch.

This is the foundation for proper offset-based dynamic ADT encoding, and it
also makes abiArrayGet's `stride = headSize(elem)` correct by construction:
in a standard-ABI element region the per-element stride IS the head footprint
(32 for offset-referenced dynamic elements, full size for inline static ones).

Static sums are unchanged (both branches static -> 32 + max), so existing
runtime tests are unaffected; no runtime path decodes a dynamic sum yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012JtxyDTDRRyoqWsXaRNp8H
Now that main splits array length into a generic Length class (and UFCS
resolves value-receiver method calls), add a calldata(array(t)):Length
instance delegating to abiArrayLength, and switch abi_array_sum's count() from
abiArrayLength(ops) to ops.length() — the same surface syntax as storage
arrays.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012JtxyDTDRRyoqWsXaRNp8H
A calldata array whose element type is dynamic (e.g. an ADT with a
memory(bytes)-carrying branch) can't be laid out inline at a fixed stride.
Give abiArrayGet the standard-ABI dynamic-element layout: after the length
word the region holds a table of 32-byte offsets (relative to the region
base), one per element, each pointing at that element's own encoding.
abiArrayGet reads offset i, rebases a fresh decoder onto the element start,
and decodes at head offset 0 — so the element's inner offsets (a memory(bytes)
leaf) resolve relative to the element, which is how they were encoded.

Static-element arrays keep the inline stride = headSize(t) layout (branch on
ABIAttribs.isStatic), so abi_array_sum and friends are unchanged. This is what
lets the nested Batch/Execute ADT in abi_batch_adt decode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012JtxyDTDRRyoqWsXaRNp8H
… on base)

ops.length() failed to resolve ("undefined name: length"): `ops` is a
parameter, so the receiver is a Var, and the base only has field-receiver
UFCS — value-receiver UFCS (calling .method() on a local/parameter) isn't
present. Call Length.length(ops) directly; it still routes through the shared
Length class and the calldata(array(t)):Length instance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012JtxyDTDRRyoqWsXaRNp8H
Value-receiver UFCS was missing on the base (only field receivers were
rewritten), so `ops.length()` on a calldata-array parameter failed with
"undefined name: length". Add the fallback in the (Just (Var c), Nothing)
Call case: when the receiver is a local/parameter and a unique class exposes
the method, rewrite recv.method(args) -> Class.method(recv, args) — the same
rule already applied to contract-field receivers, generalized to values.

Flips abi_array_sum's count() back to ops.length().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012JtxyDTDRRyoqWsXaRNp8H
`data` is a reserved keyword (ADT declaration), so the pattern binder in
`Batch.Execute(_, data)` failed to parse ("unexpected token"). Rename it to
`payload`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012JtxyDTDRRyoqWsXaRNp8H
items = [Queue(AddSigner(0xaa), Contract(0xcc)), Execute(7, 0xbeef)] encoded
with the dynamic-element array layout the decoder reads: length word, then a
32-byte offset per element (relative to the element region), then each
element's inline sum encoding, with the Execute element's memory(bytes) leaf at
an element-relative offset.

Cases: queueSigner(_,0) -> (0xaa,0xcc); queueSigner(_,1) -> (0,0) (Execute
element); execPayload(_,1) -> 0xbeef. Registered in run_contests.sh.

Selectors are computed from the structural sigString solcore derives for the
nested ADT; if a selector is off (the exact sigStr string differs), the call
won't dispatch and needs the selector captured from a sol-core run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012JtxyDTDRRyoqWsXaRNp8H
A dynamic-element calldata array (bytes[], string[], or an ADT[] whose element
is a dynamic sum) is decoded with one offset-follow convention now:

- abiArrayGet's dynamic branch hands the element decoder the region base and
  element i's 32-byte offset slot as the head offset, instead of pre-rebasing.
  The element's own dynamic decoder follows that offset.
- The sum ABIDecode branches on isStatic: a static sum is read inline (as
  before); a dynamic sum follows the offset at its head, rebases onto its start,
  then reads [tag][branch] inline. (Needs g:ABIAttribs, added.)

This makes a bare bytes/string element work (its memory(bytes) decoder follows
the table offset straight to [length][data]) — previously that double-followed
and mis-decoded — while keeping the ADT-element path (abi_batch_adt) on the
exact same calldata.

Adds abi_bytes_array (calldata(array(bytes))): at([0xaabb,0xccddee],i) and
count(...), compile + runtime fixture (standard bytes[] selectors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012JtxyDTDRRyoqWsXaRNp8H
std had string:ABIAttribs (isStatic=false) but no bytes:ABIAttribs, so bytes
fell to the default instance (isStatic=true). That wrongly marked memory(bytes)
— and any ADT carrying it, e.g. Batch via its Execute branch — as *static*, so
abiArrayGet and the sum decoder took the inline branch and read a dynamic
element's offset table as inline data (garbage). This broke abi_batch_adt and
abi_bytes_array.

Add bytes:ABIAttribs mirroring string (headSize 32, isStatic false). headSize is
unchanged from the default (32), and the single/pair arg-decode path uses
headSize + the bytes decoder's own offset-follow, not isStatic — so existing
memory(bytes)-parameter tests (concat/slices/hashes/storage) are unaffected;
only the array/sum branch selection, which is where the bug lived, changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012JtxyDTDRRyoqWsXaRNp8H
CI runs contests in order and stops at the first failure. abi_batch_adt (the
complex nested-ADT case) was running before abi_bytes_array (the simpler bare
bytes[] case, standard selector), so the isolating test was never reached.
Reorder so abi_bytes_array runs first: whichever fails first pinpoints the layer
(array + bytes decode vs the dynamic-sum decoder).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012JtxyDTDRRyoqWsXaRNp8H
…ode)

Isolation showed the bug is only in the dynamic-sum decode path (abi_bytes_array
and abi_array_sum pass; only abi_batch_adt reverts). The previous version nested
the inl/inr returns inside two matches (isStatic -> tag), and StorageGeneric
warns that inl/inr type inference is fragile there ("sum nesting off by one" in
codegen).

Restructure so the isStatic match only computes sumStartOff (a word: headOffset
for a static sum, or the followed offset for a dynamic one), then rebase once
and do a single tag match with inl/inr — the same flat shape as the original
static-only decoder. Static sums decode byte-identically (sumStartOff =
headOffset), so abi_array_sum is unchanged; dynamic sums follow their offset
first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012JtxyDTDRRyoqWsXaRNp8H
abi_batch_adt is unique in two ways vs the passing tests: it's a dynamic sum AND
it nests ADTs (Operation/Signature are ADT fields inside Batch's Queue). No
passing test decodes a nested ADT across the ABI, and the sum-decode edits
didn't move the error — so the culprit may be nested-ADT decode, not the dynamic
path itself.

abi_dyn_sum is a minimal dynamic sum with NO nested ADTs:
  DynSum = Small(uint256) | Blob(memory(bytes))
Runs before abi_batch_adt. If it passes, the dynamic-sum decode is fine and the
bug is nested-ADT decode; if it fails, the dynamic-sum decode is the culprit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012JtxyDTDRRyoqWsXaRNp8H
The abi_batch_adt.json calldata was malformed: the body was 1028 hex chars
(% 64 == 4), i.e. 4 stray nibbles from hand-pasting, which misaligned every
word. The decoder then read garbage and tripped the address dirty-bits check
(DirtyHigherBitsForAddress) -> revert, which is the "got failure" we chased.

The decode logic and selectors were correct all along (verified against the
generated Hull IR: queueSigner selector 0x773db49a = 2000532634, execPayload
0xc73c21be = 3342606782, and the sum/product/isStatic branches all lower
correctly). Regenerate the fixture programmatically so every calldata is a whole
number of 32-byte words; word values now check out (length=2, off_0=0x40,
off_1=0x100, tags, addresses aa/cc, Execute n=7, bytesOff=0x60, len=2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012JtxyDTDRRyoqWsXaRNp8H
Exercises the static-element abiArrayGet branch: address is a static value
type (headSize = 32), so elements sit inline at a fixed 32-byte stride with
left-padded 20-byte addresses (dirty-higher-bits checked on decode). Covers
both items[i] indexing and items.length() UFCS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012JtxyDTDRRyoqWsXaRNp8H
Each indexing test now calls its index function with i == length (arrays are
length 2, so i=2 is the first out-of-range index) and expects a revert with
the ArrayOutOfBounds() selector 0x7f52b2bf. This exercises the
require(i < abiArrayLength(a)) guard in abiArrayGet across the static-element
(address, sum), dynamic-element (bytes), dynamic-sum, and nested-ADT paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012JtxyDTDRRyoqWsXaRNp8H
--abi ran contractAbiJson through abiTypeOf, which had no case for a
parameterized type constructor and hit its catch-all `error`, aborting
the whole emit. The multisig's `batch(operations: calldata(array(BatchOperation)))`
tripped it: calldata(...) unwraps to array(BatchOperation), which then
fell through.

Add an `array(t)` case that maps to the Solidity array spelling `t[]`,
carrying the element's component list through (so array(pair(...)) emits
as tuple[] with components intact). Element types with no standard ABI
form (e.g. a sum-typed BatchOperation) still render as their bare name,
so array(BatchOperation) -> "BatchOperation[]" -- non-standard but no
longer fatal, matching how the emitter already handles bare ADT params.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4DS126xRP423EhyQQZ1pE
Add two reusable helpers to std.solc for EIP-712 (typed structured data
hashing & signing, https://eips.ethereum.org/EIPS/eip-712):

- eip712DomainSeparator(nameHash, versionHash, chainId, verifyingContract):
  hashStruct of the standard EIP712Domain(string name,string version,
  uint256 chainId,address verifyingContract).
- eip712Digest(domainSeparator, structHash): the 0x1901 combinator that
  binds a domain separator to a message struct hash into the final digest.

Both build on the existing keccak256/get_free_memory primitives and mirror
the memory-scratch pattern already used by ecrecover/erc7201. Message struct
hashing stays caller-side since it depends on each struct's members.

Add the canonical EIP-712 "Mail" example from the specification
(test/examples/dispatch/eip712.{solc,json}), which builds the nested Person
/ Mail struct hashes with concat/keccak256_, derives the digest via the new
std helpers, and recovers the signer with ecrecover. All domain/message/
signature values are the fixed vectors published in the EIP, so the contest
test asserts the exact domain separator, struct hash, digest, and recovered
"Cow" signer. Registered in the tasty dispatch group and run_contests.sh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172VWLDJm5D1FVWDqSGBtqV
axic and others added 29 commits July 26, 2026 04:39
Adds a contest-style JSON test suite for the Multisig contract
(test/examples/dispatch/multisig.solc) and wires it into
run_contests.sh.

Under the testrunner's fixed sender (account 0 == the sole
constructor-set signer, required threshold 1), the suite covers:

- deployment (constructor)
- selector dispatch + ABI decode of uint256, (uint256,bytes), and
  ADT-typed (Operation sum-of-products) method arguments
- operation lifecycle: queue(AddSigner) -> approve -> execute
- state-machine guards: OperationNotFound, SignerAlreadyApproved,
  IncorrectSequence (strict ordering / no re-execute)
- queue sanity check revert (ChangeSigRequired(0) below minimum)
- reject flow: rejected operations are skipped as a no-op on execute
- payable fallback: accepts bare ETH transfers, rejects calldata
- per-method payable enforcement (NonPayableReceivedValue)

Selectors are keccak256(name + "(" + structural sigStr(args) + ")")
per std/dispatch.solc; sum-typed argument calldata follows the
tag-per-branch wire layout from std/ABIGeneric.solc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4DS126xRP423EhyQQZ1pE
Extends the Multisig test suite with the ...WithSignature layer:

- registers a signer whose private key is known (AddSigner executed
  by signer[0]), then exercises queueWithSignature with a real
  secp256k1 EIP-2098 compact signature over the contract's (stubbed,
  constant) signing hash keccak256(bytes32(1)):
    * valid signer signature   -> operation queued (caller not checked)
    * non-signer signature      -> NotASigner revert (0x12345678)

This covers the ECDSA branch of checkSignature and the ADT-typed
two-argument dispatch path queueWithSignature(Operation, Signature),
whose args decode as a pair with the Signature sum read inline at
headOffset + headSize(Operation).

The contract-signature (approved-hash) and EIP-1271 branches are
intentionally omitted: both staticcall an external signer contract,
and the integration testrunner deploys only the contract under test,
so there is no way to place signer-contract code at another address.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4DS126xRP423EhyQQZ1pE
The testrunner mocks precompiles with a static input->output map and
aborts on any unseen ecrecover input. The multisig ...WithSignature
tests recover over the contract's stubbed signing hash
keccak256(bytes32(1)) = 0xb10e2d..., which no existing vector covers.

Adds the two real recoveries used by multisig.json (v=28, low-s):
- signer K   -> 0xe05fcc23807536bee418f142d19fa0d21bb0cff7
- non-signer -> 0x0376aac07ad725e01357b1725b5cec61ae10473c

Both are genuine secp256k1 recoveries, matching the r/s the JSON
passes in the EIP-2098 Signature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4DS126xRP423EhyQQZ1pE
isValidSignature(bytes32,bytes) is the ERC-1271 entry point external
verifiers call, so it must be reachable through the contract dispatch.
Marking it `public` requires an ABIEncode instance for its bytes4
return, which std lacked (only bytes32 had one) -- add bytes4:ABIEncode,
mirroring bytes32 (the word rep is right-aligned in Solcore, so it is
written directly; headSize/isStatic come from the default ABIAttribs).

Its dispatch selector is keccak256("isValidSignature(bytes32,bytes)")
= 0x1626ba7e, i.e. exactly the EIP-1271 magic value.

New multisig.json coverage (also exercises the ApproveSignedHash
operation, previously untested):
- ApproveSignedHash(h) queued/approved/executed -> approved_signed_hashes[h]
- isValidSignature(h, "")      -> returns the magic 0x1626ba7e
- isValidSignature(unknown,"") -> HashNotApproved
- isValidSignature(h, 0x01)    -> EmptySignatureExpected

The bytes4 return is encoded right-aligned to match the contract's own
consumer convention (eip1271_verify checks `res == 0x1626ba7e` as a full
word); strict left-aligned EIP-1271 wire format would need both this
instance and eip1271_verify updated together.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4DS126xRP423EhyQQZ1pE
create_signature_hash returned a constant keccak256(bytes32(1)) stub, so the
signed hash did not bind the operation. The intended
keccak256(abi.encode(kind, operation)) cannot be spelled today: std's generic
sum:ABIEncode is "static sums only", and Operation is a dynamic sum (its
Call(address, uint256, memory(bytes)) branch), so abi_encode(operation) has no
correct encoding until the symmetric dynamic-sum encode (the counterpart of the
existing dynamic-sum ABIDecode) is added to std.ABIGeneric.

Until then, derive the signing preimage explicitly with the already-working
concat/keccak256_ helpers:
  keccak256([kind tag][constructor tag][fields...])
one 32-byte word per scalar field, dynamic bytes appended verbatim. It is only
ever hashed, never decoded, so determinism + injectivity over (kind, operation)
is all that is required. A TODO marks the abi.encode collapse for later.

Because the signing hash is now operation-specific, the two stubbed ecrecover
vectors for the queueWithSignature tests are recomputed to the new hashes
(keccak256(bytes32(0) || bytes32(0) || bytes32(address)) for the AddSigner ops);
r/s/v and the recovered signer/non-signer addresses are unchanged, so
multisig.json needs no edit.

Note: not verified against the build toolchain (unavailable in this
environment) -- run `bash run_contests.sh` (multisig.json) to confirm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HdTaDVFUfQwQzJRqxNfNW1
create_signature_hash previously hashed a bare, injective preimage
  keccak256([kind tag][constructor tag][fields...])
which bound a signature to neither the chain nor the deployment, so it
could be replayed across contracts. Switch it to a proper EIP-712 typed
digest using the std.eip712 helpers:

  keccak256(0x1901 || domainSeparator || hashStruct(message))

- domain: EIP712Domain(string name,string version,uint256 chainId,
  address verifyingContract), with name "Multisig", version "1",
  chainId from chainid() and verifyingContract = this contract.
- message: MultisigOperation(uint256 kind,bytes operation), where
  `operation` is the same deterministic [tag][fields...] encoding as
  before (minus the kind word). Per EIP-712 the dynamic bytes member is
  hashed, so hashStruct binds the identical information the old preimage
  did, now domain-separated.

The testrunner mocks the ecrecover precompile with a static
(hash,v,r,s) -> address map, so the two queueWithSignature dispatch
vectors are rekeyed on the recomputed EIP-712 digests (verifyingContract
is the CREATE address of deployer 0x1212..0012 at nonce 1, chainId 1).
The r/s and recovered signer/non-signer addresses are unchanged, so the
dispatch paths exercised are identical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MuwWq7e8RCzu5ouFjVzdcu
The multisig test calldata packed the Operation and Signature arguments
inline ([tag][fields...], the static-sum layout), but Operation and
Signature are DYNAMIC sums (their Call / EIP1271 branches carry
`memory(bytes)`), so the compiler's ABIDecode (std.ABIGeneric) reads each
via a 32-byte offset pointing at a [tag][branch] tail. The inline form
only decoded correctly for AddSigner, where the leading tag word is 0 and
happens to double as a valid self-offset; every other variant was read
with its first tag word mistaken for an offset:

- queue(ChangeSigRequired(0)) mis-decoded into a no-op branch instead of
  reverting (ThresholdBelowMinimum), and being stored shifted every later
  operation index -- cascading into the execute(2) failure.
- queueWithSignature(...) mis-read the Signature (the second dynamic-sum
  arg) off the end of calldata as ECDSA(0,0), so eip2098_signer called
  ecrecover(hash, 27, 0, 0), an input the mocked precompile doesn't define
  -> abort.

Re-encode all seven Operation/Signature-bearing calldatas with proper
dynamic-sum offset encoding (nested dynamic sums rebased at each level;
inner static sums -- the bytes32-only tail -- stay inline). The decoded
operations are unchanged, so the EIP-712 signing digests and the ecrecover
vectors registered in EVMHost.cpp already match: with the signature now
decoding correctly, eip2098 yields (digest, 28, r, s) exactly as those
vectors expect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MuwWq7e8RCzu5ouFjVzdcu
…ne call)

Exercises the batching layer end-to-end: a single batch() call carrying
[Queue(AddSigner(cafe0006), ECDSA), Approve(5, ECDSA), Execute(5, "")]
queues op argotorg#5 via signature, approves it, and executes AddSigner in one
transaction. The ECDSA (r,s) is the existing signer-K vector, reused across
queue and approve because create_signature_hash currently returns a constant
hash.

The calldata is the solcore generic-ABI encoding of
calldata(array(BatchOperation)): a dynamic-element array (offset table) whose
elements are the 4-constructor BatchOperation sum, with dynamic Operation and
Signature sub-values carried via offset indirection. The batch() selector
(121765fe) is the keccak of the structural signature that dispatch derives
from BatchOperation's Generic representation.

A follow-up approve(5) asserts op argotorg#5 is now Executed (UnexpectedStatus),
confirming the batch ran all three sub-operations rather than just not
reverting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ni57Ez3ArtkjGtcVM1DJsZ
The batch() test runs [Queue(AddSigner(cafe0006)), Approve(op#5),
Execute(op#5)] in one call. With create_signature_hash now an EIP-712
digest, the two ...WithSignature legs sign distinct digests (the kind word
differs: Queue=0 vs Approve=1), so the mocked ecrecover needs a vector for
each. Both recover to the registered signer e05f..cff7, reusing the
signer-K v/r/s; only the leading EIP-712 digest differs per leg.

Digests were derived from multisig's create_signature_hash / std.eip712
(domain bound to chainId 1 and verifyingContract c06a..e79e, the CREATE
address of the deployer at nonce 1) and cross-checked by reproducing the
existing cafe0003/cafe0004 queue vectors.

Also refresh the batch test comment, which still claimed a constant signing
hash from before the EIP-712 change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ni57Ez3ArtkjGtcVM1DJsZ
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.

2 participants