diff --git a/run_contests.sh b/run_contests.sh index 985fa6761..3fab96de3 100755 --- a/run_contests.sh +++ b/run_contests.sh @@ -19,6 +19,7 @@ bash ./contest.sh test/examples/dispatch/concat.json bash ./contest.sh test/examples/dispatch/slices.json bash ./contest.sh test/examples/dispatch/fallback.json bash ./contest.sh test/examples/dispatch/ecrecover.json +bash ./contest.sh test/examples/dispatch/eip712.json bash ./contest.sh test/examples/dispatch/memory.json bash ./contest.sh test/examples/dispatch/storage.json bash ./contest.sh test/examples/dispatch/storage_array.json @@ -28,6 +29,11 @@ bash ./contest.sh test/examples/dispatch/array_copy.json bash ./contest.sh test/examples/dispatch/array_string.json bash ./contest.sh test/examples/dispatch/array_nested.json bash ./contest.sh test/examples/dispatch/generic_sum.json +bash ./contest.sh test/examples/dispatch/abi_array_sum.json +bash ./contest.sh test/examples/dispatch/abi_bytes_array.json +bash ./contest.sh test/examples/dispatch/abi_address_array.json +bash ./contest.sh test/examples/dispatch/abi_dyn_sum.json +bash ./contest.sh test/examples/dispatch/abi_batch_adt.json bash ./contest.sh test/examples/dispatch/generic_product.json bash ./contest.sh test/examples/dispatch/sum_wide_product.json bash ./contest.sh test/examples/dispatch/specialise_sum_of_product.json @@ -39,3 +45,4 @@ bash ./contest.sh test/examples/dispatch/storage_adt_abi.json bash ./contest.sh test/examples/dispatch/storage_dynamic_field.json bash ./contest.sh test/examples/dispatch/forloops.json bash ./contest.sh test/examples/dispatch/weth9.json +bash ./contest.sh test/examples/dispatch/multisig.json diff --git a/src/Solcore/Desugarer/ContractDispatch.hs b/src/Solcore/Desugarer/ContractDispatch.hs index 483f95b47..6cb2ee3eb 100644 --- a/src/Solcore/Desugarer/ContractDispatch.hs +++ b/src/Solcore/Desugarer/ContractDispatch.hs @@ -388,6 +388,13 @@ abiTypeOf (TyCon (Name "calldata") [t]) = abiTypeOf t abiTypeOf t@(TyCon (Name "pair") [_, _]) = ("tuple", map (mkAbiParam "") (flattenTuple t)) abiTypeOf (TyCon (Name "word") []) = ("uint256", []) +-- A dynamic array @array(t)@ is the ABI element type followed by @[]@, matching +-- the Solidity spelling (@t[]@). Components (only non-empty when the element is +-- a tuple) are carried through so an @array(pair(...))@ emits as @tuple[]@ with +-- its component list intact. +abiTypeOf (TyCon (Name "array") [t]) = + let (elemTy, comps) = abiTypeOf t + in (elemTy <> "[]", comps) abiTypeOf (TyCon n []) = (nameStr n, []) -- Anything else has no ABI spelling: a type variable, a function type, or a -- parameterized type constructor (e.g. @mapping(word, word)@ or a custom diff --git a/src/Solcore/Frontend/Syntax/NameResolution.hs b/src/Solcore/Frontend/Syntax/NameResolution.hs index fd9ac43fc..238fbdf82 100644 --- a/src/Solcore/Frontend/Syntax/NameResolution.hs +++ b/src/Solcore/Frontend/Syntax/NameResolution.hs @@ -732,7 +732,21 @@ resolveExp x@(S.ExpName me n es) = case fdt of Just TDataCon -> Con <$> resolveQualifiedConstructorName c n <*> pure es' - _ -> undefinedName n + _ -> + -- UFCS-style method call on a value receiver (local variable or + -- function parameter): x.method(args) -> Class.method(x, args) + -- when a unique class exposes `method`. Contract-field receivers + -- take the isUfcsReceiver path; this extends the same sugar to + -- ordinary values (e.g. a calldata-array parameter `ops.length()`). + -- Ambiguity across classes makes findClassWithMethod return + -- Nothing and falls through to the undefined-name error. + case ct of + Just dt' | dt' `elem` [TLocalVar, TParameter] -> do + mClass <- findClassWithMethod n + case mClass of + Just cls -> pure (Call Nothing (qualifyName cls n) (Var c : es')) + Nothing -> undefinedName n + _ -> undefinedName n (Just (Var c), Just TTyVar) -> do let qn = qualifyName c n cf <- gets (Map.lookup qn . scopeEnv) diff --git a/std/ABIGeneric.solc b/std/ABIGeneric.solc index ee4450502..d248f1e15 100644 --- a/std/ABIGeneric.solc +++ b/std/ABIGeneric.solc @@ -33,10 +33,18 @@ function maxWord(a : word, b : word) -> word { forall f g . f:ABIAttribs, g:ABIAttribs => instance sum(f, g) : ABIAttribs { + // Head footprint. A *dynamic* sum occupies a single offset word in the head + // (its tag + branch payload live in the tail), exactly like any other + // dynamic type. Only a fully *static* sum is laid out inline as + // tag + widest branch; there both branches are static, so their headSize is + // their full size and 32 + max(...) is the correct inline footprint. function headSize(ty : Proxy(sum(f, g))) -> word { let pf : Proxy(f); let pg : Proxy(g); - return 32 + maxWord(ABIAttribs.headSize(pf), ABIAttribs.headSize(pg)); + match and(ABIAttribs.isStatic(pf), ABIAttribs.isStatic(pg)) { + | false => return 32; + | true => return 32 + maxWord(ABIAttribs.headSize(pf), ABIAttribs.headSize(pg)); + } } function isStatic(ty : Proxy(sum(f, g))) -> bool { let pf : Proxy(f); @@ -65,25 +73,47 @@ instance sum(f, g) : ABIEncode { } // ─── ABIDecode for sum(f, g) ───────────────────────────────────────────── -// Reads the tag word at headOffset; dispatches to f or g decoder at headOffset + 32. +// A STATIC sum is laid out inline: read the tag word at headOffset, dispatch to +// the branch decoder at headOffset + 32. +// +// A DYNAMIC sum (one whose branch carries a dynamic field) is, like any dynamic +// ABI value, referenced by a 32-byte offset: read that offset at headOffset, +// rebase a decoder onto the sum's start, then read [tag][branch] inline there. +// Following the offset here (rather than at the call site) is what lets a +// dynamic sum be decoded uniformly wherever a dynamic value can appear — as a +// field, or as a `T[]` element alongside a bare `bytes`/`string` leaf, which +// follows its offset the same way. forall f g reader . reader : WordReader, f : ABIAttribs, + g : ABIAttribs, ABIDecoder(f, reader) : ABIDecode(f), ABIDecoder(g, reader) : ABIDecode(g) => instance ABIDecoder(sum(f, g), reader) : ABIDecode(sum(f, g)) { function decode(ptr : ABIDecoder(sum(f, g), reader), headOffset : word) -> sum(f, g) { match ptr { | ABIDecoder(rdr) => - let tag = WordReader.read(WordReader.advance(rdr, headOffset)); + let prx : Proxy(sum(f, g)); + // Byte offset (relative to rdr) of this sum's own start. A static sum + // is inline at headOffset; a dynamic sum's head slot holds a 32-byte + // offset to it, which we follow. We then rebase a decoder onto the + // sum start and read [tag][branch] inline — so the tag match (and its + // inl/inr) has a single, uniform shape regardless of static/dynamic. + let sumStartOff : word; + match ABIAttribs.isStatic(prx) { + | true => sumStartOff = headOffset; + | false => sumStartOff = WordReader.read(WordReader.advance(rdr, headOffset)); + } + let sumRdr = WordReader.advance(rdr, sumStartOff); + let tag = WordReader.read(sumRdr); match tag { | 0 => - let dec_f : ABIDecoder(f, reader) = ABIDecoder(rdr); - return inl(ABIDecode.decode(dec_f, headOffset + 32)); + let dec_f : ABIDecoder(f, reader) = ABIDecoder(sumRdr); + return inl(ABIDecode.decode(dec_f, 32)); | _ => - let dec_g : ABIDecoder(g, reader) = ABIDecoder(rdr); - return inr(ABIDecode.decode(dec_g, headOffset + 32)); + let dec_g : ABIDecoder(g, reader) = ABIDecoder(sumRdr); + return inr(ABIDecode.decode(dec_g, 32)); } } } diff --git a/std/dispatch.solc b/std/dispatch.solc index 8cc7be653..abe1166c9 100644 --- a/std/dispatch.solc +++ b/std/dispatch.solc @@ -76,6 +76,17 @@ instance sum(f,g):SigString { } } +// A dynamic array signs as `[]`, matching Solidity's `T[]` convention. +// The element carries its own (structural, for ADTs) signature, so an array of a +// sum type reads `sum(l,r)[]`. Location is transparent to the ABI, so this keys +// on the calldata form the dispatch decodes from. +forall t. t:SigString => +instance calldata(array(t)):SigString { + function sigStr(x:Proxy(calldata(array(t)))) -> string { + SigString.sigStr( Proxy:Proxy(t) ) + "[]" + } +} + // Any data type inherits its ABI signature from its Generic representation, the // same way ABIAttribs / ABIEncode bridge through Generic in std.ABIGeneric. This // lets the dispatch take ADT-typed parameters (e.g. a Signature) without a diff --git a/std/eip712.solc b/std/eip712.solc new file mode 100644 index 000000000..9e6f687be --- /dev/null +++ b/std/eip712.solc @@ -0,0 +1,57 @@ +import std.{*}; +import std.opcodes.{mstore, keccak256, shl}; + +export { + eip712Digest, + eip712DomainSeparator +}; + +// --- EIP-712 (typed structured data hashing & signing) --- +// https://eips.ethereum.org/EIPS/eip-712 +// +// The digest a wallet signs is +// keccak256(0x19 0x01 ‖ domainSeparator ‖ hashStruct(message)) +// where every `hashStruct(s)` is `keccak256(typeHash ‖ encodeData(s))` and the +// domain separator is the `hashStruct` of the standard EIP712Domain struct. +// +// Encoding a struct's members is application specific (it depends on which +// members the struct has and whether they are atomic or dynamic), so the +// message struct hash is built by the caller. The two reusable pieces live +// here: the domain separator for the common `EIP712Domain(string name,string +// version,uint256 chainId,address verifyingContract)` shape, and the `0x1901` +// digest combinator that binds a domain separator to a message struct hash. + +// hashStruct of the standard EIP712Domain. `nameHash` / `versionHash` are the +// keccak256 of the (dynamic) name / version strings — typically compile-time +// constants produced with `keccakLit`. `chainId` / `verifyingContract` are +// encoded as their left-padded 32-byte words. +function eip712DomainSeparator( + nameHash: bytes32, + versionHash: bytes32, + chainId: uint256, + verifyingContract: address +) -> bytes32 { + let typeHash = keccakLit("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); + // Lay the five 32-byte words out contiguously and hash them. We borrow the + // area above the free-memory pointer as scratch (as `ecrecover` does): the + // preimage is consumed immediately by keccak256 and never needs to persist, + // so there is no need to bump the free pointer. + let ptr = get_free_memory(); + mstore(ptr, typeHash); + mstore(ptr + 32, Typedef.rep(nameHash)); + mstore(ptr + 64, Typedef.rep(versionHash)); + mstore(ptr + 96, Typedef.rep(chainId)); + mstore(ptr + 128, Typedef.rep(verifyingContract)); + return bytes32(keccak256(ptr, 160)); +} + +// Binds a domain separator to a message's struct hash, yielding the final +// EIP-712 digest: keccak256(0x19 0x01 ‖ domainSeparator ‖ structHash). The +// two-byte 0x1901 prefix occupies the leading bytes of the first word. +function eip712Digest(domainSeparator: bytes32, structHash: bytes32) -> bytes32 { + let ptr = get_free_memory(); + mstore(ptr, shl(240, 0x1901)); // 0x1901 in the leading two bytes + mstore(ptr + 2, Typedef.rep(domainSeparator)); + mstore(ptr + 34, Typedef.rep(structHash)); + return bytes32(keccak256(ptr, 66)); +} diff --git a/std/std.solc b/std/std.solc index d422cdcf9..76f89195e 100644 --- a/std/std.solc +++ b/std/std.solc @@ -1142,10 +1142,26 @@ forall t . instance DynArray(t):ABIAttribs { function headSize(ty : Proxy(DynArray(t))) -> word { return 32; } function isStatic(ty : Proxy(DynArray(t))) -> bool { return false; } } +// A dynamic array is encoded head-first as a 32-byte offset into the tail, so +// its head is one word and it is never static (matching DynArray above). This +// covers `array(t)` under any location qualifier via the `calldata(ty)` / +// `memory(ty)` ABIAttribs bridges. +forall t . instance array(t):ABIAttribs { + function headSize(ty : Proxy(array(t))) -> word { return 32; } + function isStatic(ty : Proxy(array(t))) -> bool { return false; } +} instance string:ABIAttribs { function headSize(ty: Proxy(string)) -> word { return 32; } function isStatic(ty : Proxy(string)) -> bool { return false; } } +// bytes is dynamic, exactly like string — without this instance it falls to the +// default (isStatic = true), which wrongly marks memory(bytes) (and any ADT +// carrying it) static, so calldata arrays/sums take the inline decode path over +// what is really an offset-referenced value. +instance bytes:ABIAttribs { + function headSize(ty: Proxy(bytes)) -> word { return 32; } + function isStatic(ty : Proxy(bytes)) -> bool { return false; } +} // computes the attribs for a pair of two types that implement attribs forall a b . a:ABIAttribs, b:ABIAttribs => instance (a,b):ABIAttribs { @@ -1248,6 +1264,17 @@ instance bytes32:ABIEncode { } } +instance bytes4:ABIEncode { + // bytes4's word rep is right-aligned in Solcore (e.g. `bytes4(shr(224, h))`), + // so it is written directly into the head like bytes32. headSize/isStatic + // come from the default ABIAttribs instance (32, static). + function encodeInto(x:bytes4, basePtr:word, offset:word, tail:word) -> word { + let repx : word = Typedef.rep(x); + mstore(basePtr + offset, repx); + return tail; + } +} + instance bool:ABIEncode { function encodeInto(x:bool, basePtr:word, offset:word, tail:word) -> word { let repx : word = frombool(x); @@ -1546,6 +1573,76 @@ forall baseType baseType_decoded . ABIDecoder(baseType, CalldataWordReader):ABID } } +// ─── Lazy ABI decode of a calldata dynamic array ───────────────────────────── +// The head slot holds the (args-relative) byte offset to the array data; +// following it lands on the length word. The decoded value is a calldata handle +// to that length word, so the elements are left in calldata and decoded on +// demand (abiArrayLength / abiArrayGet). Because nothing is materialised here, +// this works for any decodable element type — including multi-word ADTs such as +// a sum(...) — which the word-per-slot memory(DynArray(...)) path cannot hold. +forall baseType baseType_decoded . + ABIDecoder(baseType, CalldataWordReader):ABIDecode(baseType_decoded) => + instance ABIDecoder(calldata(array(baseType)), CalldataWordReader):ABIDecode(calldata(array(baseType_decoded))) + { + function decode(ptr:ABIDecoder(calldata(array(baseType)), CalldataWordReader), currentHeadOffset:word) -> calldata(array(baseType_decoded)) { + let headRdr = WordReader.advance(ptr, currentHeadOffset); + let dataOffset : word = WordReader.read(headRdr); + let dataRdr = WordReader.advance(ptr, dataOffset); + let rdr : CalldataWordReader = getReader(dataRdr); + let addr : word = Typedef.rep(rdr); + return Typedef.abs(addr); + } + } + +// Length of a decoded calldata array: the handle points at the length word. +forall t . function abiArrayLength(a : calldata(array(t))) -> uint256 { + let rdr : CalldataWordReader = CalldataWordReader(Typedef.rep(a)); + return uint256(WordReader.read(rdr)); +} + +// Decode element `i` of a calldata array on demand. The element region starts +// one word after the handle (past the length word). Two layouts, per the ABI: +// +// * static element type -> elements sit inline, each headSize(t) bytes, so +// element i starts at (handle + 32) + i * headSize(t). The element decoder +// is aimed at the region base and the per-element offset is threaded as the +// head offset. +// +// * dynamic element type -> the region holds a table of 32-byte offsets (one +// per element, relative to the region base), each pointing at that +// element's own encoding (standard-ABI T[] for dynamic T). The element +// decoder is aimed at the region base and given element i's slot as its +// head offset; the element's own dynamic decoder follows that offset. This +// is uniform across element kinds: a dynamic sum follows it and rebases to +// the element start, a bare bytes/string leaf follows it to its length word. +forall t t_decoded . + t : ABIAttribs, + ABIDecoder(t, CalldataWordReader):ABIDecode(t_decoded) => +function abiArrayGet(a : calldata(array(t)), i : uint256) -> t_decoded { + // Bounds check: valid indices are [0, length); i == length is already past + // the last element, so reject i >= length (mirrors the storage-array guard). + require(i < abiArrayLength(a), Error(0x7f52b2bf)); // ArrayOutOfBounds() + let base : word = Typedef.rep(a); + let elemRegion : word = base + 32; + let prx : Proxy(t); + let idx : word = Typedef.rep(i); + match ABIAttribs.isStatic(prx) { + | true => + let elemRdr : CalldataWordReader = CalldataWordReader(elemRegion); + let dec : ABIDecoder(t, CalldataWordReader) = ABIDecoder(elemRdr); + return ABIDecode.decode(dec, idx * ABIAttribs.headSize(prx)); + | false => + // Dynamic elements: the region is a table of 32-byte offsets (relative + // to the region base), one per element. Hand the element decoder the + // region base and element i's slot as its head offset; the element's own + // (dynamic) decoder follows that offset — uniformly for a dynamic sum + // element or a bare bytes/string element (calldata(array(bytes))). + let elemRdr : CalldataWordReader = CalldataWordReader(elemRegion); + let dec : ABIDecoder(t, CalldataWordReader) = ABIDecoder(elemRdr); + return ABIDecode.decode(dec, idx * 32); + } +} + // --- Assignment --- @@ -1824,6 +1921,16 @@ instance storage(array(t)):Length { } } +// A lazily-decoded calldata array reports its length from the head length-word +// of its handle (see abiArrayLength), so `arr.length()` resolves through the +// same Length class / UFCS as storage arrays. +forall t . +instance calldata(array(t)):Length { + function length(arr:calldata(array(t))) -> uint256 { + return abiArrayLength(arr); + } +} + forall t . instance storage(array(t)):Array { // Shrinking clears the abandoned slots, matching solc's resize_array. @@ -2220,6 +2327,22 @@ instance (storage(array(a)), i): RValueIdxAccess(v) { } } +// Indexed read of a lazily-decoded calldata array: `arr[i]` desugars to +// ridx(arr, i), which dispatches here and decodes element i on demand via +// abiArrayGet. There is deliberately no LValueIdxAccess instance — calldata is +// immutable, so `arr[i] = …` is (correctly) rejected at compile time. +forall t t_decoded i . + t : ABIAttribs, + ABIDecoder(t, CalldataWordReader):ABIDecode(t_decoded), + i : Typedef(word) => +instance (calldata(array(t)), i): RValueIdxAccess(t_decoded) { + function lookup(xi : (calldata(array(t)), i)) -> t_decoded { + match(xi) { + | (a, idx) => return abiArrayGet(a, uint256(Typedef.rep(idx))); + } + } +} + // Mapping reads go through CanStore, matching the write side (Assign -> CanStore.store). // This lets a mapping hold any value with a CanStore instance — including ADTs whose // fields are dynamic (memory(bytes)) — not just the fixed-slot StorageType primitives. diff --git a/test/Cases.hs b/test/Cases.hs index d2ecdbf3c..8caf53345 100644 --- a/test/Cases.hs +++ b/test/Cases.hs @@ -125,10 +125,16 @@ dispatches = runDispatchTest "miniERC20.solc", runDispatchTest "Revert.solc", runDispatchTest "hashes.solc", + runDispatchTest "eip712.solc", runDispatchTest "empty.solc", runDispatchTest "empty_no_constructor.solc", runDispatchTest "generic_product.solc", runDispatchTest "generic_sum.solc", + runDispatchTest "abi_array_sum.solc", + runDispatchTest "abi_batch_adt.solc", + runDispatchTest "abi_bytes_array.solc", + runDispatchTest "abi_dyn_sum.solc", + runDispatchTest "abi_address_array.solc", runDispatchTest "specialise_sum_of_product.solc", runDispatchTest "storage_adt_field.solc", runDispatchTest "storage_adt_enum.solc", diff --git a/test/examples/dispatch/abi_address_array.json b/test/examples/dispatch/abi_address_array.json new file mode 100644 index 000000000..22c97e746 --- /dev/null +++ b/test/examples/dispatch/abi_address_array.json @@ -0,0 +1,64 @@ +{ + "abi_address_array": { + "bytecode": "", + "contract": "AddressArr", + "tests": [ + { + "input": { + "comment": "constructor()", + "calldata": "", + "value": "0" + }, + "kind": "constructor" + }, + { + "input": { + "comment": "at([0x1111..,0x2222..],0) -> 0x1111..", + "calldata": "0df1b95100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000011111111111111111111111111111111111111110000000000000000000000002222222222222222222222222222222222222222", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "0000000000000000000000001111111111111111111111111111111111111111", + "status": "success" + } + }, + { + "input": { + "comment": "at([0x1111..,0x2222..],1) -> 0x2222..", + "calldata": "0df1b95100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000200000000000000000000000011111111111111111111111111111111111111110000000000000000000000002222222222222222222222222222222222222222", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "0000000000000000000000002222222222222222222222222222222222222222", + "status": "success" + } + }, + { + "input": { + "comment": "count([0x1111..,0x2222..]) -> 2", + "calldata": "6e82212b0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000200000000000000000000000011111111111111111111111111111111111111110000000000000000000000002222222222222222222222222222222222222222", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "0000000000000000000000000000000000000000000000000000000000000002", + "status": "success" + } + }, + { + "input": { + "comment": "at([..],2) -> revert ArrayOutOfBounds (len 2)", + "calldata": "0df1b95100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000200000000000000000000000011111111111111111111111111111111111111110000000000000000000000002222222222222222222222222222222222222222", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "7f52b2bf", + "status": "failure" + } + } + ] + } +} diff --git a/test/examples/dispatch/abi_address_array.solc b/test/examples/dispatch/abi_address_array.solc new file mode 100644 index 000000000..f74497012 --- /dev/null +++ b/test/examples/dispatch/abi_address_array.solc @@ -0,0 +1,23 @@ +import std.{*}; +import std.dispatch.{*}; +import std.Generic.{*}; +import std.ABIGeneric.{*}; + +// calldata(array(address)) — a dynamic array of a STATIC value type. Unlike +// bytes[] (dynamic elements, offset table), address is static, so elements sit +// inline at a fixed 32-byte stride (headSize(address) = 32). Each element is a +// left-padded 20-byte address; decoding checks the high 12 bytes are zero +// (DirtyHigherBitsForAddress). Exercises the static-element abiArrayGet branch. +contract AddressArr { + constructor() {} + + // The i-th address. + public function at(items : calldata(array(address)), i : uint256) -> address { + return items[i]; + } + + // Number of elements. + public function count(items : calldata(array(address))) -> uint256 { + return items.length(); + } +} diff --git a/test/examples/dispatch/abi_array_sum.json b/test/examples/dispatch/abi_array_sum.json new file mode 100644 index 000000000..6941e2346 --- /dev/null +++ b/test/examples/dispatch/abi_array_sum.json @@ -0,0 +1,88 @@ +{ + "abi_array_sum": { + "bytecode": "", + "contract": "Batch", + "tests": [ + { + "input": { + "comment": "constructor()", + "calldata": "", + "value": "0" + }, + "kind": "constructor" + }, + { + "input": { + "comment": "count([Approve(10),Reject(20)]) -> 2", + "calldata": "c2fb594e000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000014", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "0000000000000000000000000000000000000000000000000000000000000002", + "status": "success" + } + }, + { + "input": { + "comment": "tagOf(ops,0) -> 16 (Approve)", + "calldata": "dd072b850000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000014", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "0000000000000000000000000000000000000000000000000000000000000010", + "status": "success" + } + }, + { + "input": { + "comment": "tagOf(ops,1) -> 32 (Reject)", + "calldata": "dd072b850000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000014", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "0000000000000000000000000000000000000000000000000000000000000020", + "status": "success" + } + }, + { + "input": { + "comment": "amountOf(ops,0) -> 10", + "calldata": "94cc5e2c0000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000014", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "000000000000000000000000000000000000000000000000000000000000000a", + "status": "success" + } + }, + { + "input": { + "comment": "amountOf(ops,1) -> 20", + "calldata": "94cc5e2c0000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000014", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "0000000000000000000000000000000000000000000000000000000000000014", + "status": "success" + } + }, + { + "input": { + "comment": "tagOf(ops,2) -> revert ArrayOutOfBounds (len 2)", + "calldata": "dd072b850000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000014", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "7f52b2bf", + "status": "failure" + } + } + ] + } +} diff --git a/test/examples/dispatch/abi_array_sum.solc b/test/examples/dispatch/abi_array_sum.solc new file mode 100644 index 000000000..c6a790f60 --- /dev/null +++ b/test/examples/dispatch/abi_array_sum.solc @@ -0,0 +1,48 @@ +import std.{*}; +import std.dispatch.{*}; +import std.Generic.{*}; +import std.ABIGeneric.{*}; + +// ABI-decoding a dynamic array whose element is a sum-typed ADT. +// +// `Operation` has two constructors, so its Generic representation is the +// primitive sum `sum(uint256, uint256)` (Approve = inl, Reject = inr). Each +// wire element is therefore two words — a tag word then the payload — which the +// word-per-slot memory(DynArray(...)) representation cannot hold. The array is +// instead decoded lazily from calldata: the parameter becomes a +// `calldata(array(Operation))` handle to the length word, and elements are +// decoded on demand. Indexing uses the ordinary `ops[i]` sugar (calldata-array +// RValueIdxAccess) and `ops.length()` uses the Length-class UFCS — the same +// surface syntax as storage arrays. `ops` is a parameter, so this relies on +// value-receiver UFCS (NameResolution), not just the field-receiver form. +data Operation = Approve(uint256) | Reject(uint256); + +contract Batch { + constructor() {} + + // Number of operations in the array. + public function count(ops : calldata(array(Operation))) -> uint256 { + return ops.length(); + } + + // Constructor of element i, mapped to a distinct sentinel: 16 for Approve, + // 32 for Reject. Deliberately not 0/1 — those coincide with the on-wire sum + // tag (inl=0, inr=1), so non-trivial values prove the match actually + // discriminates the constructor rather than echoing the raw tag word. + public function tagOf(ops : calldata(array(Operation)), i : uint256) -> uint256 { + let op : Operation = ops[i]; + match op { + | Operation.Approve(_) => return uint256(16); + | Operation.Reject(_) => return uint256(32); + } + } + + // Payload (the uint256) of element i, regardless of constructor. + public function amountOf(ops : calldata(array(Operation)), i : uint256) -> uint256 { + let op : Operation = ops[i]; + match op { + | Operation.Approve(v) => return v; + | Operation.Reject(v) => return v; + } + } +} diff --git a/test/examples/dispatch/abi_batch_adt.json b/test/examples/dispatch/abi_batch_adt.json new file mode 100644 index 000000000..506c1d02a --- /dev/null +++ b/test/examples/dispatch/abi_batch_adt.json @@ -0,0 +1,64 @@ +{ + "abi_batch_adt": { + "bytecode": "", + "contract": "BatchDecoder", + "tests": [ + { + "input": { + "comment": "constructor()", + "calldata": "", + "value": "0" + }, + "kind": "constructor" + }, + { + "input": { + "comment": "queueSigner([Queue(AddSigner(0xaa),Contract(0xcc)),Execute(7,0xbeef)],0) -> (0xaa,0xcc)", + "calldata": "773db49a000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000aa000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000cc00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000700000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000002beef000000000000000000000000000000000000000000000000000000000000", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "00000000000000000000000000000000000000000000000000000000000000aa00000000000000000000000000000000000000000000000000000000000000cc", + "status": "success" + } + }, + { + "input": { + "comment": "queueSigner(...,1) -> (0,0): element 1 is Execute, not Queue", + "calldata": "773db49a000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000aa000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000cc00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000700000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000002beef000000000000000000000000000000000000000000000000000000000000", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "status": "success" + } + }, + { + "input": { + "comment": "execPayload(...,1) -> 0xbeef", + "calldata": "c73c21be000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000aa000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000cc00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000700000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000002beef000000000000000000000000000000000000000000000000000000000000", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000002beef000000000000000000000000000000000000000000000000000000000000", + "status": "success" + } + }, + { + "input": { + "comment": "queueSigner([..],2) -> revert ArrayOutOfBounds (len 2)", + "calldata": "773db49a000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000aa000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000cc00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000700000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000002beef000000000000000000000000000000000000000000000000000000000000", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "7f52b2bf", + "status": "failure" + } + } + ] + } +} diff --git a/test/examples/dispatch/abi_batch_adt.solc b/test/examples/dispatch/abi_batch_adt.solc new file mode 100644 index 000000000..74390c275 --- /dev/null +++ b/test/examples/dispatch/abi_batch_adt.solc @@ -0,0 +1,73 @@ +import std.{*}; +import std.dispatch.{*}; +import std.Generic.{*}; +import std.ABIGeneric.{*}; + +// Complex nested-ADT ABI decode over a calldata dynamic array. The element is a +// three-level algebraic type built from sums *and* products: +// +// Operation : sum(address, address) -- static +// Signature : sum((bytes32, bytes32), address) -- static +// Batch : sum( (Operation, Signature) -- Queue : static, inline +// , (uint256, memory(bytes)) ) -- Execute : dynamic (carries bytes) +// +// `items[i]` dispatches through the calldata-array RValueIdxAccess instance to +// abiArrayGet, decoding the element on demand into a fully-formed `Batch` to +// match on — exercising the derived ABIDecode across three nested data types. +// +// `Batch` is a *dynamic* element (its Execute branch carries a memory(bytes)), +// so the array uses the offset-table layout: after the length word comes one +// 32-byte offset per element (relative to the element region), each pointing at +// that element's own encoding. abiArrayGet rebases onto the element start, and +// the element's inner offsets (the memory(bytes) leaf) resolve relative to the +// element — so both the static Queue path and the dynamic Execute path decode. +// +// This is currently registered via runDispatchTest, which compiles the contract +// through the whole pipeline. A runtime .json fixture (exercising the decode on +// real calldata) needs the exact solcore-generated selector for the nested-ADT +// signature, which has to be captured from a local sol-core run. + +data Operation = AddSigner(address) | RemoveSigner(address); +data Signature = ECDSA(bytes32, bytes32) | Contract(address); +data Batch = Queue(Operation, Signature) | Execute(uint256, memory(bytes)); + +// Address added by an AddSigner op (address(0) for a RemoveSigner). +function addedSigner(op : Operation) -> address { + match op { + | Operation.AddSigner(a) => return a; + | Operation.RemoveSigner(_) => return address(0); + } +} + +// Verifying contract address of a Contract signature (address(0) for ECDSA). +function contractVerifier(sig : Signature) -> address { + match sig { + | Signature.Contract(a) => return a; + | Signature.ECDSA(_, _) => return address(0); + } +} + +contract BatchDecoder { + constructor() {} + + // From a Queue(AddSigner(a), Contract(c)) element, return (a, c): the signer + // being added and the contract that verifies the queued action. + public function queueSigner(items : calldata(array(Batch)), i : uint256) -> (address, address) { + let b : Batch = items[i]; + match b { + | Batch.Queue(op, sig) => return (addedSigner(op), contractVerifier(sig)); + | Batch.Execute(_, _) => return (address(0), address(0)); + } + } + + // The payload bytes carried by an Execute element. + public function execPayload(items : calldata(array(Batch)), i : uint256) -> memory(bytes) { + let b : Batch = items[i]; + let out : memory(bytes); + match b { + | Batch.Execute(_, payload) => out = payload; + | Batch.Queue(_, _) => revertEmpty(); + } + return out; + } +} diff --git a/test/examples/dispatch/abi_bytes_array.json b/test/examples/dispatch/abi_bytes_array.json new file mode 100644 index 000000000..7aff3495c --- /dev/null +++ b/test/examples/dispatch/abi_bytes_array.json @@ -0,0 +1,64 @@ +{ + "abi_bytes_array": { + "bytecode": "", + "contract": "BytesArray", + "tests": [ + { + "input": { + "comment": "constructor()", + "calldata": "", + "value": "0" + }, + "kind": "constructor" + }, + { + "input": { + "comment": "at([0xaabb,0xccddee],0) -> 0xaabb", + "calldata": "3034aef5000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000002aabb0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003ccddee0000000000000000000000000000000000000000000000000000000000", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000002aabb000000000000000000000000000000000000000000000000000000000000", + "status": "success" + } + }, + { + "input": { + "comment": "at([0xaabb,0xccddee],1) -> 0xccddee", + "calldata": "3034aef5000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000002aabb0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003ccddee0000000000000000000000000000000000000000000000000000000000", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000003ccddee0000000000000000000000000000000000000000000000000000000000", + "status": "success" + } + }, + { + "input": { + "comment": "count([0xaabb,0xccddee]) -> 2", + "calldata": "1926560b00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000002aabb0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003ccddee0000000000000000000000000000000000000000000000000000000000", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "0000000000000000000000000000000000000000000000000000000000000002", + "status": "success" + } + }, + { + "input": { + "comment": "at([..],2) -> revert ArrayOutOfBounds (len 2)", + "calldata": "3034aef5000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000002aabb0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003ccddee0000000000000000000000000000000000000000000000000000000000", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "7f52b2bf", + "status": "failure" + } + } + ] + } +} diff --git a/test/examples/dispatch/abi_bytes_array.solc b/test/examples/dispatch/abi_bytes_array.solc new file mode 100644 index 000000000..694c8f753 --- /dev/null +++ b/test/examples/dispatch/abi_bytes_array.solc @@ -0,0 +1,25 @@ +import std.{*}; +import std.dispatch.{*}; +import std.Generic.{*}; +import std.ABIGeneric.{*}; + +// calldata(array(bytes)) — a dynamic array whose element is itself dynamic, the +// canonical Solidity `bytes[]`. After the length word the region is a table of +// 32-byte offsets (relative to the region base), one per element, each pointing +// at that element's `[length][data]` encoding. `items[i]` decodes the i-th +// element on demand: abiArrayGet hands the element decoder the region base + +// element i's slot, and the memory(bytes) decoder follows that offset to the +// element's length word — no ADT wrapper needed, unlike abi_batch_adt. +contract BytesArray { + constructor() {} + + // The i-th bytes element. + public function at(items : calldata(array(memory(bytes))), i : uint256) -> memory(bytes) { + return items[i]; + } + + // Number of elements. + public function count(items : calldata(array(memory(bytes)))) -> uint256 { + return items.length(); + } +} diff --git a/test/examples/dispatch/abi_dyn_sum.json b/test/examples/dispatch/abi_dyn_sum.json new file mode 100644 index 000000000..a8ab053d0 --- /dev/null +++ b/test/examples/dispatch/abi_dyn_sum.json @@ -0,0 +1,52 @@ +{ + "abi_dyn_sum": { + "bytecode": "", + "contract": "DynSumArr", + "tests": [ + { + "input": { + "comment": "constructor()", + "calldata": "", + "value": "0" + }, + "kind": "constructor" + }, + { + "input": { + "comment": "smallOf([Small(42),Blob(0xbeef)],0) -> 42", + "calldata": "17136f37000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000002beef000000000000000000000000000000000000000000000000000000000000", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "000000000000000000000000000000000000000000000000000000000000002a", + "status": "success" + } + }, + { + "input": { + "comment": "blobOf([Small(42),Blob(0xbeef)],1) -> 0xbeef", + "calldata": "59a154a8000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000002beef000000000000000000000000000000000000000000000000000000000000", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000002beef000000000000000000000000000000000000000000000000000000000000", + "status": "success" + } + }, + { + "input": { + "comment": "smallOf([..],2) -> revert ArrayOutOfBounds (len 2)", + "calldata": "17136f37000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000002beef000000000000000000000000000000000000000000000000000000000000", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "7f52b2bf", + "status": "failure" + } + } + ] + } +} diff --git a/test/examples/dispatch/abi_dyn_sum.solc b/test/examples/dispatch/abi_dyn_sum.solc new file mode 100644 index 000000000..d1c0f66b5 --- /dev/null +++ b/test/examples/dispatch/abi_dyn_sum.solc @@ -0,0 +1,37 @@ +import std.{*}; +import std.dispatch.{*}; +import std.Generic.{*}; +import std.ABIGeneric.{*}; + +// Minimal dynamic sum in a calldata array — like abi_batch_adt but with NO +// nested ADTs: the constructors carry primitive / bytes fields directly. This +// isolates the dynamic-sum decode path (which abi_batch_adt exercises and +// abi_array_sum does not) from nested-ADT decode (an ADT field inside an ADT, +// which abi_batch_adt also has and this test does not). +// +// DynSum : sum(uint256, bytes) -- dynamic (Blob carries memory(bytes)) +data DynSum = Small(uint256) | Blob(memory(bytes)); + +contract DynSumArr { + constructor() {} + + // The uint256 in a Small element (0 for a Blob). + public function smallOf(items : calldata(array(DynSum)), i : uint256) -> uint256 { + let d : DynSum = items[i]; + match d { + | DynSum.Small(x) => return x; + | DynSum.Blob(_) => return uint256(0); + } + } + + // The bytes payload of a Blob element. + public function blobOf(items : calldata(array(DynSum)), i : uint256) -> memory(bytes) { + let d : DynSum = items[i]; + let out : memory(bytes); + match d { + | DynSum.Blob(b) => out = b; + | DynSum.Small(_) => revertEmpty(); + } + return out; + } +} diff --git a/test/examples/dispatch/eip712.json b/test/examples/dispatch/eip712.json new file mode 100644 index 000000000..782955cbb --- /dev/null +++ b/test/examples/dispatch/eip712.json @@ -0,0 +1,64 @@ +{ + "eip712": { + "bytecode": "", + "contract": "EIP712Mail", + "tests": [ + { + "input": { + "comment": "constructor()", + "calldata": "", + "value": "0" + }, + "kind": "constructor" + }, + { + "input": { + "comment": "domainSeparator()(bytes32)", + "calldata": "f698da25", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "f2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f", + "status": "success" + } + }, + { + "input": { + "comment": "structHash()(bytes32)", + "calldata": "220861ea", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "c52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e", + "status": "success" + } + }, + { + "input": { + "comment": "digest()(bytes32)", + "calldata": "52a82b65", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "be609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2", + "status": "success" + } + }, + { + "input": { + "comment": "verify()(address)", + "calldata": "fc735e99", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "000000000000000000000000cd2a3d9f938e13cd947ec05abc7fe734df8dd826", + "status": "success" + } + } + ] + } +} diff --git a/test/examples/dispatch/eip712.solc b/test/examples/dispatch/eip712.solc new file mode 100644 index 000000000..a292b5b9e --- /dev/null +++ b/test/examples/dispatch/eip712.solc @@ -0,0 +1,97 @@ +import std.{*}; +import std.dispatch.{*}; +import std.eip712.{*}; + +// Canonical EIP-712 example from the specification +// (https://eips.ethereum.org/EIPS/eip-712): a `Mail` sent from one `Person` to +// another. It shows how to build the nested message struct hashes on top of the +// reusable `eip712DomainSeparator` / `eip712Digest` helpers in std, then recover +// the signer with `ecrecover`. +// +// struct EIP712Domain { string name; string version; uint256 chainId; address verifyingContract; } +// struct Person { string name; address wallet; } +// struct Mail { Person from; Person to; string contents; } +// +// Every value below (domain, message and signature) is a fixed vector published +// in the EIP, so `verify()` must recover the "Cow" signer +// 0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826. + +// A struct's members are ABI-encoded (each atomic member as its 32-byte word, a +// dynamic member as the keccak256 of its contents) and prefixed with the struct +// type hash, then hashed. We build the byte string with `concat` and hash it +// with `keccak256_`, exactly as in the slices example. + +// hashStruct(Person) = keccak256(PERSON_TYPEHASH ‖ keccak256(name) ‖ wallet) +function hashPerson(nameHash: bytes32, wallet: address) -> bytes32 { + let typeHash = bytes32(keccakLit("Person(string name,address wallet)")); + return keccak256_( + concat(typeHash, concat(nameHash, bytes32(Typedef.rep(wallet)))) + ); +} + +// hashStruct(Mail) = keccak256(MAIL_TYPEHASH ‖ hashStruct(from) ‖ hashStruct(to) ‖ keccak256(contents)) +// The Mail type hash embeds the referenced Person type per the EIP-712 rule for +// nested structs (referenced types are appended, sorted by name). +function hashMail(fromHash: bytes32, toHash: bytes32, contentsHash: bytes32) -> bytes32 { + let typeHash = bytes32( + keccakLit("Mail(Person from,Person to,string contents)Person(string name,address wallet)") + ); + return keccak256_( + concat(typeHash, concat(fromHash, concat(toHash, contentsHash))) + ); +} + +// Domain separator for name "Ether Mail", version "1", chainId 1 and the fixed +// verifying contract from the spec. Uses the std EIP712Domain helper. +function mailDomainSeparator() -> bytes32 { + return eip712DomainSeparator( + bytes32(keccakLit("Ether Mail")), + bytes32(keccakLit("1")), + uint256(1), + address(0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC) + ); +} + +// hashStruct of the fixed Mail message. +function mailStructHash() -> bytes32 { + let fromHash = hashPerson( + bytes32(keccakLit("Cow")), + address(0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826) + ); + let toHash = hashPerson( + bytes32(keccakLit("Bob")), + address(0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB) + ); + let contentsHash = bytes32(keccakLit("Hello, Bob!")); + return hashMail(fromHash, toHash, contentsHash); +} + +function mailDigest() -> bytes32 { + return eip712Digest(mailDomainSeparator(), mailStructHash()); +} + +contract EIP712Mail { + constructor() {} + + // Intermediate hashes, exposed so each EIP-712 layer can be asserted. + public function domainSeparator() -> bytes32 { + return mailDomainSeparator(); + } + + public function structHash() -> bytes32 { + return mailStructHash(); + } + + public function digest() -> bytes32 { + return mailDigest(); + } + + // Recovers the signer of the fixed Mail message using the published + // signature. Returns the "Cow" wallet 0xCD2a3d…D826. + public function verify() -> address { + let v: uint256 = uint256(28); + let r: bytes32 = bytes32(0x4355c47d63924e8a72e509b65029052eb6c299d53a04e167c5775fd466751c9d); + let s: bytes32 = bytes32(0x07299936d304c153f6443dfa05f40ff007d72911b6f72307f996231605b91562); + return ecrecover(mailDigest(), v, r, s); + } +} diff --git a/test/examples/dispatch/multisig.json b/test/examples/dispatch/multisig.json new file mode 100644 index 000000000..ae2bf2cd9 --- /dev/null +++ b/test/examples/dispatch/multisig.json @@ -0,0 +1,388 @@ +{ + "multisig": { + "bytecode": "", + "contract": "Multisig", + "tests": [ + { + "input": { + "comment": "constructor() -- deployer becomes signer[0], required=1", + "calldata": "", + "value": "0" + }, + "kind": "constructor" + }, + { + "input": { + "comment": "approve(0) with no queued ops -> OperationNotFound (0x12345678)", + "calldata": "b759f9540000000000000000000000000000000000000000000000000000000000000000", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "12345678", + "status": "failure" + } + }, + { + "input": { + "comment": "reject(0) with no queued ops -> OperationNotFound (0x12345678)", + "calldata": "b8adaa110000000000000000000000000000000000000000000000000000000000000000", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "12345678", + "status": "failure" + } + }, + { + "input": { + "comment": "execute(0, \"\") with no queued ops -> OperationNotFound (0x12345678)", + "calldata": "59efcb15000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "12345678", + "status": "failure" + } + }, + { + "input": { + "comment": "queue(AddSigner(cafe0001)) by signer[0] -> stored as op #0", + "calldata": "4ae6f8ce0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cafe0001", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "", + "status": "success" + } + }, + { + "input": { + "comment": "approve(0) by signer[0] -> Approvals(0)->Approvals(1)", + "calldata": "b759f9540000000000000000000000000000000000000000000000000000000000000000", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "", + "status": "success" + } + }, + { + "input": { + "comment": "approve(0) again -> SignerAlreadyApproved (0x12345678)", + "calldata": "b759f9540000000000000000000000000000000000000000000000000000000000000000", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "12345678", + "status": "failure" + } + }, + { + "input": { + "comment": "execute(0, \"\") -> count(1)>=required(1): runs AddSigner, signers_count=2", + "calldata": "59efcb15000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "", + "status": "success" + } + }, + { + "input": { + "comment": "execute(0, \"\") again -> IncorrectSequence, nonce advanced (0x12345678)", + "calldata": "59efcb15000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "12345678", + "status": "failure" + } + }, + { + "input": { + "comment": "queue(ChangeSigRequired(0)) -> ThresholdBelowMinimum, not stored (0x12345678)", + "calldata": "4ae6f8ce0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "12345678", + "status": "failure" + } + }, + { + "input": { + "comment": "queue(RemoveSigner(cafe0001)) by signer[0] -> stored as op #1", + "calldata": "4ae6f8ce000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cafe0001", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "", + "status": "success" + } + }, + { + "input": { + "comment": "reject(1) by signer[0] -> status(op#1)=Rejected", + "calldata": "b8adaa110000000000000000000000000000000000000000000000000000000000000001", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "", + "status": "success" + } + }, + { + "input": { + "comment": "execute(1, \"\") -> Rejected op is skipped as a no-op (success)", + "calldata": "59efcb15000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "", + "status": "success" + } + }, + { + "input": { + "comment": "queue(AddSigner(1bb0cff7)) -> register the ECDSA signer as op #2", + "calldata": "4ae6f8ce00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e05fcc23807536bee418f142d19fa0d21bb0cff7", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "", + "status": "success" + } + }, + { + "input": { + "comment": "approve(2) by signer[0] -> Approvals(1)", + "calldata": "b759f9540000000000000000000000000000000000000000000000000000000000000002", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "", + "status": "success" + } + }, + { + "input": { + "comment": "execute(2, \"\") -> runs AddSigner, ECDSA signer now signers[2], count=3", + "calldata": "59efcb15000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "", + "status": "success" + } + }, + { + "input": { + "comment": "queueWithSignature(AddSigner(cafe0003), ECDSA sig by signer K) -> valid signer, op #3 stored", + "calldata": "f0ebe9e100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cafe00030000000000000000000000000000000000000000000000000000000000000000a5f2175cf703916c00bc39e47cd6895a40939ca418200fd16f3a6f0e6e946e728d88a02b70b20799677813021b7e5cb4c566a1e2d4eb12f85d38e0a50e3d03af", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "", + "status": "success" + } + }, + { + "input": { + "comment": "queueWithSignature(AddSigner(cafe0004), ECDSA sig by NON-signer J) -> NotASigner (0x12345678)", + "calldata": "f0ebe9e100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cafe00040000000000000000000000000000000000000000000000000000000000000000cb81aced75b14a861ae712060e0c37ae22a73d87c8f947114bb38f11b61e89cc90665fa4084d71f4a9d413062464d475494dafddc08fefeb7857c1f27d699e58", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "12345678", + "status": "failure" + } + }, + { + "input": { + "comment": "receive ETH: empty calldata + value -> payable fallback accepts", + "calldata": "", + "value": "1000000000000000000" + }, + "kind": "call", + "output": { + "returndata": "", + "status": "success" + } + }, + { + "input": { + "comment": "unknown selector deadc0de -> fallback reverts (calldatasize!=0) (0x12345678)", + "calldata": "deadc0de", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "12345678", + "status": "failure" + } + }, + { + "input": { + "comment": "unknown selector deadc0de + value -> payable fallback still rejects data (0x12345678)", + "calldata": "deadc0de", + "value": "42" + }, + "kind": "call", + "output": { + "returndata": "12345678", + "status": "failure" + } + }, + { + "input": { + "comment": "approve(0) with value -> NonPayable method rejects callvalue (0xb5988ea3)", + "calldata": "b759f9540000000000000000000000000000000000000000000000000000000000000000", + "value": "1" + }, + "kind": "call", + "output": { + "returndata": "b5988ea3", + "status": "failure" + } + }, + { + "input": { + "comment": "approve(3) -> Approvals(1) for the pending queueWithSignature op #3", + "calldata": "b759f9540000000000000000000000000000000000000000000000000000000000000003", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "", + "status": "success" + } + }, + { + "input": { + "comment": "execute(3, \"\") -> runs AddSigner(cafe0003), nonce advances to 4", + "calldata": "59efcb15000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "", + "status": "success" + } + }, + { + "input": { + "comment": "queue(ApproveSignedHash(0x11..11)) by signer[0] -> stored as op #4", + "calldata": "4ae6f8ce000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000001111111111111111111111111111111111111111111111111111111111111111", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "", + "status": "success" + } + }, + { + "input": { + "comment": "approve(4) -> Approvals(1)", + "calldata": "b759f9540000000000000000000000000000000000000000000000000000000000000004", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "", + "status": "success" + } + }, + { + "input": { + "comment": "execute(4, \"\") -> approved_signed_hashes[0x11..11] = true", + "calldata": "59efcb15000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "", + "status": "success" + } + }, + { + "input": { + "comment": "isValidSignature(0x11..11, \"\") -> approved + empty sig -> EIP-1271 magic 0x1626ba7e", + "calldata": "1626ba7e111111111111111111111111111111111111111111111111111111111111111100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "000000000000000000000000000000000000000000000000000000001626ba7e", + "status": "success" + } + }, + { + "input": { + "comment": "isValidSignature(0x22..22, \"\") -> hash not approved -> HashNotApproved (0x12345678)", + "calldata": "1626ba7e222222222222222222222222222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "12345678", + "status": "failure" + } + }, + { + "input": { + "comment": "isValidSignature(0x11..11, 0x01) -> non-empty sig -> EmptySignatureExpected (0x12345678)", + "calldata": "1626ba7e1111111111111111111111111111111111111111111111111111111111111111000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000010100000000000000000000000000000000000000000000000000000000000000", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "12345678", + "status": "failure" + } + }, + { + "input": { + "comment": "batch([Queue(AddSigner(cafe0006), ECDSA sig by signer K), Approve(5, ECDSA sig by K), Execute(5, \"\")]) -> queues op #5, approves it, then executes AddSigner(cafe0006) in one call. The queue and approve legs sign distinct EIP-712 digests (kind Queue vs Approve); both recover to registered signer K via the mocked ecrecover vectors in EVMHost.cpp.", + "calldata": "121765fe000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000002600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cafe00060000000000000000000000000000000000000000000000000000000000000000a5f2175cf703916c00bc39e47cd6895a40939ca418200fd16f3a6f0e6e946e728d88a02b70b20799677813021b7e5cb4c566a1e2d4eb12f85d38e0a50e3d03af000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000a5f2175cf703916c00bc39e47cd6895a40939ca418200fd16f3a6f0e6e946e728d88a02b70b20799677813021b7e5cb4c566a1e2d4eb12f85d38e0a50e3d03af00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "", + "status": "success" + } + }, + { + "input": { + "comment": "approve(5) after the batch -> op #5 exists and is Executed, so status is not Approvals -> UnexpectedStatus (0x12345678). Confirms the batch queued+approved+executed op #5.", + "calldata": "b759f9540000000000000000000000000000000000000000000000000000000000000005", + "value": "0" + }, + "kind": "call", + "output": { + "returndata": "12345678", + "status": "failure" + } + } + ] + } +} diff --git a/test/examples/dispatch/multisig.solc b/test/examples/dispatch/multisig.solc new file mode 100644 index 000000000..1c31f875c --- /dev/null +++ b/test/examples/dispatch/multisig.solc @@ -0,0 +1,532 @@ +// +// The design of this multisig is fairly simple. +// +// An Operation defines a state change, and OperationStatus defines +// its current status. Each Operation must be approved by enough Signers. +// We store Signers, Operations and OperationStatuses in storage. +// +// An existing Signer can queue, approve, or reject an Operation. Once +// an Operation is approved, anyone can execute it. OperationStatus controls +// it as a state machine. +// +// The states of an Operation: +// - upon creation, called `queue`, the state becomes Approvals(0), where 0 means 0 approvals +// - with `approve` the state increments Approvals(i) to Approvals (i + 1) +// - with `reject` the state changes to Rejected iff the current state is Approvals(i) +// - with `execute` the state changes to Executed iff the current state is Approvals(i) with i >= signers_required +// +// Operations must be executed in strict order. If something becomes Rejected, it must +// still be executed, and execution will mark and skip it. Note that if a transaction +// becomes non-executable for any reason, it can be marked as rejected and skipped. +// +// The second layer is queueWithSignature/approveWithSignature/rejectWithSignature, +// where a signature is passed along and thus the caller is not checked. This +// signature can be multiple options: +// - EIP-2098 compact ECDSA signature, +// - approved hash by target contract, which must be a signer, +// - EIP-1271 contract signature validation, which must be a signer. +// +// The last layer is batching operations. +// +// Optional future improvements: +// - Operation.ChangeSigner -- batched change to replace a given signer +// - Operation.DelegateCall -- it is a security surface, and not neccessarily needed +// - be an EIP-1271 signer +// - gas optimisations +// - Strict sequence vs. re-entracy guard for execute() + +import std.{*}; +import std.dispatch.{*}; +import std.opcodes.{address as address_, calldatasize, mload, caller as caller_, chainid}; +import std.eip712.{eip712Digest, eip712DomainSeparator}; +import std.ABIGeneric.{*}; +import std.StorageGeneric.{*}; + +function caller() -> address { + return address(caller_()); +} + +data Operation = + AddSigner(address) // Adds a new signer. + | RemoveSigner(address) // Removes an existing signer. + | ChangeSigRequired(uint256) // Change the number of signatures required. + | TransferEth(address, uint256) // Transfers ether. + | TransferToken(address, address, uint256) // Transfers a token. + | Call(address, uint256, memory(bytes)) // Arbitrary calls to an address. + | UnstoredCall(bytes32) // Arbitrary calls to an address, represented by a hash (supplied at execution time). + // It is encoded as [address][value][payload] + | ApproveSignedHash(bytes32) // For interacting as an EIP-1271 signer. + | RevokeSignedHash(bytes32); + +data OperationStatus = + Approvals(uint256) // approval count (TODO: use uint8/uint16 to be realistic) + | Rejected + | Executed; + +data Vote = + None + | Approved + | Rejected; + +data Signature = + ECDSA(bytes32, bytes32) // EIP-2098-style r/s/v (over the EIP-712 digest, see create_signature_hash) + | Contract(address) // If the hash is approved by the contract. + | EIP1271(address, memory(bytes)); // EIP-1271 signature validation + +data OperationKind = + Queue + | Approve + | Reject; + +data BatchOperation = + Queue(Operation, Signature) + | Approve(uint256, Signature) + | Reject(uint256, Signature) + | Execute(uint256, memory(bytes)); + + +instance Vote:Eq { + function eq(a: Vote, b: Vote) -> bool { + let a_index: word; + match a { + | Vote.None => a_index = 0; + | Vote.Approved => a_index = 1; + | Vote.Rejected => a_index = 2; + } + let b_index: word; + match b { + | Vote.None => b_index = 0; + | Vote.Approved => b_index = 1; + | Vote.Rejected => b_index = 2; + } + return a_index == b_index; + } +} + +contract Multisig { + signers: mapping(uint256, address); // TODO use array() + signers_count: uint256; + signers_required: uint256; + // TODO: Stored by hash -- or should it be by nonce? + operations: mapping(uint256, Operation); // TODO: use array() + operations_count: uint256; + votes: mapping(uint256, mapping(address, Vote)); + status: mapping(uint256, OperationStatus); + nonce: uint256; // Strict ordering. Next executable operation. + approved_signed_hashes: mapping(bytes32, bool); + + constructor() { + // The creator becomes the first signer. + signers[uint256(0)] = caller(); + signers_count = 1; + signers_required = 1; + } + + // Only signers can call this. + public function queue(op: Operation) -> () { + require(isSigner(caller()), Error(0x12345678)); // NotASigner() + perform_queue(op); + } + + function perform_queue(op: Operation) -> () { + // Some basic sanity checks. + match op { + | Operation.AddSigner(signer) => + require(signer != address(0), Error(0x12345678)); // CannotAddZeroAddressAsSigner() + require(signer != address(address_()), Error(0x12345678)); // CannotAddSelfAsSigner() + | Operation.ChangeSigRequired(count) => + require(count >= 1, Error(0x12345678)); // ThresholdBelowMinimum() + | _ => + } + + operations[operations_count] = op; + status[operations_count] = OperationStatus.Approvals(0); + operations_count += 1; + + // TODO: emit log + } + + // Only signers can call this. + public function approve(nonce_: uint256) -> () { + require(isSigner(caller()), Error(0x12345678)); // NotASigner() + perform_approve(nonce_, caller()); + } + + function perform_approve(nonce_: uint256, signer: address) -> () { + require(nonce_ < operations_count, Error(0x12345678)); // OperationNotFound() + + // TODO: emit log + + match status[nonce_] { + | OperationStatus.Approvals(count) => + require(votes[nonce_][signer] == Vote.None, Error(0x12345678)); // SignerAlreadyApproved() + votes[nonce_][signer] = Vote.Approved; + status[nonce_] = OperationStatus.Approvals(count + 1); + | _ => revertWithError(Error(0x12345678)); // UnexpectedStatus() + } + } + + function checkSignature(hash: bytes32, signature: Signature) -> address { + match signature { + | Signature.ECDSA(r, s) => + let signer = eip2098_signer(hash, r, s); + require(isSigner(signer), Error(0x12345678)); // NotASigner() + return signer; + | Signature.Contract(contract_) => + require(isSigner(contract_), Error(0x12345678)); // NotASigner() + require(check_contract_hash(contract_, hash), Error(0x12345678)); // HashNotApprovedByTarget() + return contract_; + | Signature.EIP1271(contract_, signature) => + require(isSigner(contract_), Error(0x12345678)); // NotASigner() + require(eip1271_verify(contract_, hash, signature), Error(0x12345678)); // EIP1271VerificationRejected() + return contract_; + } + } + + function create_signature_hash(kind: OperationKind, operation: Operation) -> bytes32 { + // EIP-712 typed-data signing (https://eips.ethereum.org/EIPS/eip-712). + // The wallet signs + // keccak256(0x1901 || domainSeparator || hashStruct(message)) + // binding the signature to this chain and this contract, so a signature + // for one deployment cannot be replayed against another. + // + // The message type is + // MultisigOperation(uint256 kind,bytes operation) + // where `kind` is the operation kind (queue/approve/reject) and + // `operation` is a deterministic, injective byte encoding of the + // Operation sum + // [constructor tag][operation fields...] + // one 32-byte word per scalar field, with a dynamic bytes payload + // appended verbatim. Per EIP-712 a dynamic `bytes` member is encoded as + // its keccak256, so hashStruct binds the very same information the raw + // preimage used to -- now under a domain-separated 0x1901 digest. + // + // TODO: collapse the `operation` encoding to abi_encode(operation) once + // std's generic ABIEncode supports *dynamic* sums. Today sum:ABIEncode + // in std.ABIGeneric is "static sums only" (it writes [tag][payload] + // inline with no offset-into-tail handling), while Operation is a + // dynamic sum -- its Call(address, uint256, memory(bytes)) branch drags + // a dynamic field in -- so abi_encode(operation) cannot be encoded + // correctly yet. The dynamic-sum ABIDecode already exists; the symmetric + // encode is the missing half. + let op_bytes: memory(bytes); + match operation { + | Operation.AddSigner(signer) => + op_bytes = concat(bytes32(0), bytes32(Typedef.rep(signer))); + | Operation.RemoveSigner(signer) => + op_bytes = concat(bytes32(1), bytes32(Typedef.rep(signer))); + | Operation.ChangeSigRequired(count) => + op_bytes = concat(bytes32(2), bytes32(Typedef.rep(count))); + | Operation.TransferEth(target, amount) => + op_bytes = concat(concat(bytes32(3), bytes32(Typedef.rep(target))), bytes32(Typedef.rep(amount))); + | Operation.TransferToken(target, token, amount) => + op_bytes = concat(concat(concat(bytes32(4), bytes32(Typedef.rep(target))), bytes32(Typedef.rep(token))), bytes32(Typedef.rep(amount))); + | Operation.Call(target, value, payload) => + op_bytes = concat(concat(concat(bytes32(5), bytes32(Typedef.rep(target))), bytes32(Typedef.rep(value))), payload); + | Operation.UnstoredCall(hash) => + op_bytes = concat(bytes32(6), hash); + | Operation.ApproveSignedHash(hash) => + op_bytes = concat(bytes32(7), hash); + | Operation.RevokeSignedHash(hash) => + op_bytes = concat(bytes32(8), hash); + } + + // hashStruct(message) = keccak256(typeHash || kind || keccak256(operation)) + let typeHash = keccakLit("MultisigOperation(uint256 kind,bytes operation)"); + let structHash = keccak256_( + concat(concat(bytes32(typeHash), operation_kind_tag(kind)), keccak256_(op_bytes)) + ); + + // Domain: EIP712Domain(string name,string version,uint256 chainId,address verifyingContract) + let domainSeparator = eip712DomainSeparator( + bytes32(keccakLit("Multisig")), + bytes32(keccakLit("1")), + uint256(chainid()), + address(address_()) + ); + + return eip712Digest(domainSeparator, structHash); + } + + function operation_kind_tag(kind: OperationKind) -> bytes32 { + match kind { + | OperationKind.Queue => return bytes32(0); + | OperationKind.Approve => return bytes32(1); + | OperationKind.Reject => return bytes32(2); + } + } + + // Anyone can call this. + public function queueWithSignature(operation: Operation, signature: Signature) -> () { + let hash = create_signature_hash(OperationKind.Queue, operation); + + checkSignature(hash, signature); + + perform_queue(operation); + } + + // Anyone can call this. + public function approveWithSignature(nonce_: uint256, signature: Signature) -> () { + let hash = create_signature_hash(OperationKind.Approve, operations[nonce_]); + + let signer = checkSignature(hash, signature); + + perform_approve(nonce_, signer); + } + + // Anyone can call this. + public function rejectWithSignature(nonce_: uint256, signature: Signature) -> () { + let hash = create_signature_hash(OperationKind.Reject, operations[nonce_]); + + let signer = checkSignature(hash, signature); + + perform_reject(nonce_, signer); + } + + public function batch(operations: calldata(array(BatchOperation))) -> () { + for (let i = uint256(0); i < operations.length(); i += 1) { + match operations[i] { + | BatchOperation.Queue(operation, signature) => queueWithSignature(operation, signature); + | BatchOperation.Approve(nonce_, signature) => approveWithSignature(nonce_, signature); + | BatchOperation.Reject(nonce_, signature) => rejectWithSignature(nonce_, signature); + | BatchOperation.Execute(nonce_, payload) => execute(nonce_, payload); + } + } + } + + // Only signers can call this. + public function reject(nonce_: uint256) -> () { + require(isSigner(caller()), Error(0x12345678)); // NotASigner() + perform_reject(nonce_, caller()); + } + + function perform_reject(nonce_: uint256, signer: address) -> () { + require(nonce_ < operations_count, Error(0x12345678)); // OperationNotFound() + + // TODO: emit log + + match status[nonce_] { + | OperationStatus.Approvals(count) => + status[nonce_] = OperationStatus.Rejected; + votes[nonce_][signer] = Vote.Rejected; + | _ => revertWithError(Error(0x12345678)); // UnexpectedStatus() + } + } + + // Anyone can execute, as long as the status is correct. + // Payload is optional, used in case UnstoredCall is encountered. + public function execute(nonce_: uint256, payload: memory(bytes)) -> () { + // Ensure status. + require(nonce_ < operations_count, Error(0x12345678)); // OperationNotFound() + + // Enforce strict sequence ordering. + require(nonce_ == nonce, Error(0x12345678)); // IncorrectSequence() + match status[nonce_] { + | OperationStatus.Rejected => + nonce += 1; + // Special case for rejections: we operate as a no-op. + return (); + | OperationStatus.Approvals(count) => + require(count >= signers_required, Error(0x12345678)); // NotEnoughApprovals() + nonce += 1; + // Update status. + status[nonce_] = OperationStatus.Executed; + | _ => revertWithError(Error(0x12345678)); // IncorrectStatus(); + } + + // TODO: emit log + + // Execute. + match operations[nonce_] { + | Operation.AddSigner(signer) => add_signer(signer); + | Operation.RemoveSigner(signer) => remove_signer(signer); + | Operation.ChangeSigRequired(count) => + require(count <= signers_count, Error(0x12345678)); // ThresholdExceedsSigners() + signers_required = count; + | Operation.TransferEth(target, amount) => + let ret: word; + let target_ = Typedef.rep(target); + let amount_ = Typedef.rep(amount); + assembly { + ret := call(gas(), target_, amount_, 0, 0, 0, 0) + } + require(tobool(ret), Error(0x12345678)); // EtherTransferFailed() + | Operation.TransferToken(target, token, amount) => + safe_erc20_transfer(token, target, amount); + | Operation.Call(target, value, payload) => + require(arbitrary_call(target, value, payload), Error(0x12345678)); // CallFailed() + | Operation.UnstoredCall(hash) => + require(hash == keccak256_(payload), Error(0x12345678)); // InvalidPayloadSupplied() + let ret: word; + let payload_ = Typedef.rep(payload); + assembly { + let size := mload(payload_) + // Check for minimum length of 64 bytes + if lt(size, 64) { + revert(0, 0) // TODO return proper error + } + let target := mload(add(payload_, 32)) + let value := mload(add(payload_, 64)) + ret := call(gas(), target, value, add(payload_, 96), sub(size, 64), 0, 0) + } + require(tobool(ret), Error(0x12345678)); // UnstoredCallFailed() + | Operation.ApproveSignedHash(hash) => + // Sanity check. + require(!approved_signed_hashes[hash], Error(0x12345678)); // ApprovedSignedHashExist() + approved_signed_hashes[hash] = true; + | Operation.RevokeSignedHash(hash) => + // Sanity check. + require(approved_signed_hashes[hash], Error(0x12345678)); // ApprovedSignedHashDoesNotExist() + approved_signed_hashes[hash] = false; + | _ => unimplemented(); // TODO + } + } + + payable fallback() -> () { + // Accept incoming payments if no selector is hit. + require(calldatasize() == 0, Error(0x12345678)); // UnexpectedEtherTransfer() + + // TODO: emit log + } + + // ERC-1271 receiver -- external verifiers call this, so it must be dispatched. + // NOTE: view function. + public function isValidSignature(hash: bytes32, signature: memory(bytes)) -> bytes4 { + require(approved_signed_hashes[hash], Error(0x12345678)); // HashNotApproved(); + let signature_ = Typedef.rep(signature); + require(mload(signature_) == 0, Error(0x12345678)); // EmptySignatureExpected() + // TODO: consider pass-through signature checking if the hash is not found (needs passing data and not hash) + return bytes4(0x1626ba7e); + } + + // TODO: these functions should be non-public + + // TODO: this is suboptimal + function isSigner(signer: address) -> bool { + for (let i = 0; i < signers_count; i += 1) { + if (signers[i] == signer) { + return true; + } + } + return false; + } + + function add_signer(signer: address) -> () { + require(!isSigner(signer), Error(0x12345678)); // SignerAlreadyExists() + signers[signers_count] = signer; + signers_count += 1; + } + + function remove_signer(signer: address) -> () { + require(signers_count > 1, Error(0x12345678)); // CannotRemoveOnlySigner() + for (let i = 0; i < signers_count; i += 1) { + if (signers[i] == signer) { + // Move last signer into this place. + signers[i] = signers[signers_count - 1]; + signers_count -= 1; + // Reduce requirement if needed. + if (signers_count < signers_required) { + signers_required = signers_count; + } + return (); + } + } + revertWithError(Error(0x12345678)); // NotASigner() + } +} + +function check_contract_hash(contract__: address, hash: bytes32) -> bool { + let ptr = get_free_memory(); + let contract_ = Typedef.rep(contract__); + let hash_ = Typedef.rep(hash); + let res: word; + let ret: word; + // We assume the [0, 32] scratch space is reserved. + // TODO: add specific error code + assembly { + mstore(ptr, shl(224, 0x12345678)) // IsHashApproved(bytes32) + mstore(add(ptr, 4), hash_) + // Alternative option is ignoring ret, but setting mem[0] to 0. + ret := staticcall(gas(), contract_, ptr, 36, 0, 32) + res := mload(0) + } + return ret == 1 && res == 0x12345678; // Must match the magic. +} + +function eip1271_verify(contract__: address, hash: bytes32, signature: memory(bytes)) -> bool { + let ptr = get_free_memory(); + let contract_ = Typedef.rep(contract__); + let hash_ = Typedef.rep(hash); + let signature_ = Typedef.rep(signature); + let res: word; + let ret: word; + // We assume the [0, 32] scratch space is reserved. + // TODO: add specific error code + assembly { + // TODO: use abi.encode to build this + mstore(ptr, shl(224, 0x1626ba7e)) + mstore(add(ptr, 4), hash_) + mstore(add(ptr, 36), 64) + let size := mload(signature_) + mstore(add(ptr, 68), size) + mcopy(add(ptr, 100), add(signature_, 32), size) + // Alternative option is ignoring ret, but setting mem[0] to 0. + ret := staticcall(gas(), contract_, ptr, add(100, size), 0, 32) + res := mload(0) + } + return ret == 1 && res == 0x1626ba7e; // Must match the magic. +} + +function eip2098_signer(hash: bytes32, r: bytes32, s_: bytes32) -> address { + let s__ = Typedef.rep(s_); + let s: word; + let v: word; + assembly { + s := and(s__, sub(shl(255, 1), 1)) + v := add(shr(255, s__), 27) + } +// let parity = match v { +// | 27 => Even, +// | 28 => Odd, +// } + return ecrecover(hash, uint256(v), r, bytes32(s)); +} + +// Performs a safe transfer of ERC-20 tokens. Makes sure the call succeeded, +// and if the token follows the standard and returns a boolean, that is also true. +function safe_erc20_transfer(token: address, to: address, value: uint256) -> () { + let ptr = get_free_memory(); + let token_ = Typedef.rep(token); + let to_ = Typedef.rep(to); + let value_ = Typedef.rep(value); + assembly { + // Assemble the [selector][address][value] + mstore(ptr, shl(224, 0xa9059cbb)) + mstore(add(ptr, 4), to_) + mstore(add(ptr, 36), value_) + let ret := call(gas(), token_, 0, ptr, 68, 0, 32) + if iszero(ret) { + // Bubble up error. + returndatacopy(0, 0, returndatasize()) + revert(0, returndatasize()) + } + // If the token follows the standard and returns a bool, check it returned true. + // This allows any non-zero value as true, just like OpenZeppelin. + if returndatasize() { + if iszero(mload(0)) { + revert(0, 0) // TODO: use error codes + } + } + } +} + +function arbitrary_call(target: address, value: uint256, payload: memory(bytes)) -> bool { + let target_ = Typedef.rep(target); + let value_ = Typedef.rep(value); + let payload_ = Typedef.rep(payload); + let ret: word; + assembly { + ret := call(gas(), target_, value_, add(payload_, 32), mload(payload_), 0, 0) + } + return tobool(ret); +} diff --git a/test/testrunner/EVMHost.cpp b/test/testrunner/EVMHost.cpp index eb788df7b..8d7c1d3ca 100644 --- a/test/testrunner/EVMHost.cpp +++ b/test/testrunner/EVMHost.cpp @@ -553,6 +553,93 @@ evmc::Result EVMHost::precompileECRecover(evmc_message const& _message) noexcept fromHex(""), gas_cost } + }, + { + // EIP-712 canonical "Mail" example digest (test/examples/dispatch/eip712). + fromHex( + "be609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2" + "000000000000000000000000000000000000000000000000000000000000001c" + "4355c47d63924e8a72e509b65029052eb6c299d53a04e167c5775fd466751c9d" + "07299936d304c153f6443dfa05f40ff007d72911b6f72307f996231605b91562" + ), + { + fromHex("000000000000000000000000cd2a3d9f938e13cd947ec05abc7fe734df8dd826"), + gas_cost + } + }, + // Vectors for the multisig ...WithSignature dispatch tests. The signing + // hash is the multisig's create_signature_hash, now an EIP-712 digest + // keccak256(0x1901 || domainSeparator || hashStruct(MultisigOperation)) + // bound to chainId 1 and verifyingContract 0xc06a..e79e (the CREATE + // address of deployer 0x1212..0012 at nonce 1 -- where the testrunner + // deploys the Multisig). Both entries are queueWithSignature (kind + // Queue = 0) of an AddSigner operation. + // + // The ecrecover precompile is mocked, so these map the (digest, v, r, s) + // preimage to a fixed recovered address rather than performing a real + // recovery: one a registered signer, one not, which is all the dispatch + // paths exercise. The v/r/s are the EIP-2098 values the JSON passes in; + // only the leading 32-byte digest is recomputed from the EIP-712 + // encoding. If create_signature_hash (or the domain) changes, recompute + // these two digests to match -- see the derivation in multisig.solc. + { + // queueWithSignature(AddSigner(0x..cafe0003)) -> signer e05f..cff7 + fromHex( + "5ad92c8921886a17148f249897ecb1fff50f65567ee7d37471c681ac29c02833" + "000000000000000000000000000000000000000000000000000000000000001c" + "a5f2175cf703916c00bc39e47cd6895a40939ca418200fd16f3a6f0e6e946e72" + "0d88a02b70b20799677813021b7e5cb4c566a1e2d4eb12f85d38e0a50e3d03af" + ), + { + fromHex("000000000000000000000000e05fcc23807536bee418f142d19fa0d21bb0cff7"), + gas_cost + } + }, + { + // queueWithSignature(AddSigner(0x..cafe0004)) -> non-signer 0376..473c + fromHex( + "9af78d208cb2e8e4540ab23677e17edeb5e54f2dbc3fa2dc9751bb82b6d25d13" + "000000000000000000000000000000000000000000000000000000000000001c" + "cb81aced75b14a861ae712060e0c37ae22a73d87c8f947114bb38f11b61e89cc" + "10665fa4084d71f4a9d413062464d475494dafddc08fefeb7857c1f27d699e58" + ), + { + fromHex("0000000000000000000000000376aac07ad725e01357b1725b5cec61ae10473c"), + gas_cost + } + }, + // Vectors for the batch() lifecycle test, which runs + // [Queue(AddSigner(0x..cafe0006)), Approve(op#5), Execute(op#5)] in a + // single call. The two ...WithSignature legs sign distinct EIP-712 + // digests -- the kind word differs (Queue=0 vs Approve=1) while the + // operation (AddSigner(0x..cafe0006)) is the same -- so each needs its + // own (digest, v, r, s) -> signer mapping. Both recover to the + // registered signer e05f..cff7; the v/r/s reuse the signer-K vector. + { + // batch element 0: queueWithSignature(AddSigner(0x..cafe0006)) [kind Queue] + fromHex( + "6690978f4de1fd3f6aedb8d49a6ed2adc7372dd9bdb84596e3e71c7ed2e722e5" + "000000000000000000000000000000000000000000000000000000000000001c" + "a5f2175cf703916c00bc39e47cd6895a40939ca418200fd16f3a6f0e6e946e72" + "0d88a02b70b20799677813021b7e5cb4c566a1e2d4eb12f85d38e0a50e3d03af" + ), + { + fromHex("000000000000000000000000e05fcc23807536bee418f142d19fa0d21bb0cff7"), + gas_cost + } + }, + { + // batch element 1: approveWithSignature(op#5 = AddSigner(0x..cafe0006)) [kind Approve] + fromHex( + "80552bfb8d50b1a6b28ffb173aff1c7018d9e219ca7f5ad2cc9a9ebd7c7ac7dd" + "000000000000000000000000000000000000000000000000000000000000001c" + "a5f2175cf703916c00bc39e47cd6895a40939ca418200fd16f3a6f0e6e946e72" + "0d88a02b70b20799677813021b7e5cb4c566a1e2d4eb12f85d38e0a50e3d03af" + ), + { + fromHex("000000000000000000000000e05fcc23807536bee418f142d19fa0d21bb0cff7"), + gas_cost + } } }; evmc::Result result = precompileGeneric(_message, inputOutput, true /* _ignoresTrailingInput */);