From 4ea11b9fb9c6a0ab0886b5deca94a3d4f669386d Mon Sep 17 00:00:00 2001 From: Jacek Sieka Date: Fri, 4 Oct 2024 13:46:58 +0200 Subject: [PATCH] transaction signing helpers (#742) Transaction signing is something that happens in a lot of places - this PR introduces primitives for transaction signing in `transaction_utils` such that we can use the same logic across web3/eth1/etc for this simple operation. `transaction_utils` also contains a few more "spec-derived" helpers for working with transactions, such as the computation of a contract address etc that cannot easily be introduced in `transactions` itself without bringing in dependencies like secp and rlp, so they end up in a separate module. Finally, since these modules collect "versions" of these transaction types across different eips, some tests are moved to follow the same structure. --- eth/common/eth_types.nim | 3 - eth/common/transaction_utils.nim | 77 ++++++++++ eth/common/transactions.nim | 8 ++ eth/common/transactions_rlp.nim | 30 ++-- tests/common/all_tests.nim | 6 +- tests/common/test_common.nim | 2 +- tests/common/test_eip7702.nim | 134 ------------------ tests/common/test_eth_types_rlp.nim | 1 - tests/common/test_receipts.nim | 39 +++++ ...test_eip4844.nim => test_transactions.nim} | 89 ++++++++++-- 10 files changed, 221 insertions(+), 168 deletions(-) create mode 100644 eth/common/transaction_utils.nim delete mode 100644 tests/common/test_eip7702.nim create mode 100644 tests/common/test_receipts.nim rename tests/common/{test_eip4844.nim => test_transactions.nim} (74%) diff --git a/eth/common/eth_types.nim b/eth/common/eth_types.nim index 3d00c81..8291282 100644 --- a/eth/common/eth_types.nim +++ b/eth/common/eth_types.nim @@ -41,9 +41,6 @@ type EthReceipt* = Receipt EthWithdrawapRequest* = WithdrawalRequest -template contractCreation*(tx: Transaction): bool = - tx.to.isNone - func init*(T: type BlockHashOrNumber, str: string): T {.raises: [ValueError].} = if str.startsWith "0x": if str.len != sizeof(default(T).hash.data) * 2 + 2: diff --git a/eth/common/transaction_utils.nim b/eth/common/transaction_utils.nim new file mode 100644 index 0000000..3d1afbb --- /dev/null +++ b/eth/common/transaction_utils.nim @@ -0,0 +1,77 @@ +# eth +# Copyright (c) 2024 Status Research & Development GmbH +# Licensed and distributed under either of +# * MIT license (license terms in the root directory or at https://opensource.org/licenses/MIT). +# * Apache v2 license (license terms in the root directory or at https://www.apache.org/licenses/LICENSE-2.0). +# at your option. This file may not be copied, modified, or distributed except according to those terms. + +import ./[keys, transactions, transactions_rlp] + +export keys, transactions + +proc signature*(tx: Transaction): Opt[Signature] = + var bytes {.noinit.}: array[65, byte] + bytes[0 .. 31] = tx.R.toBytesBE() + bytes[32 .. 63] = tx.S.toBytesBE() + + bytes[64] = + if tx.txType != TxLegacy: + tx.V.byte + elif tx.V >= EIP155_CHAIN_ID_OFFSET: + byte(1 - (tx.V and 1)) + elif tx.V == 27 or tx.V == 28: + byte(tx.V - 27) + else: + return Opt.none(Signature) + + Signature.fromRaw(bytes).mapConvertErr(void) + +proc `signature=`*(tx: var Transaction, param: tuple[sig: Signature, eip155: bool]) = + let raw = param.sig.toRaw() + + tx.R = UInt256.fromBytesBE(raw.toOpenArray(0, 31)) + tx.S = UInt256.fromBytesBE(raw.toOpenArray(32, 63)) + + let v = raw[64].uint64 + tx.V = + case tx.txType + of TxLegacy: + if param.eip155: + v + uint64(tx.chainId) * 2 + 35 + else: + v + 27'u64 + else: + v + +proc sign*(tx: Transaction, pk: PrivateKey, eip155: bool): (Signature, bool) = + let hash = tx.rlpHashForSigning(eip155) + + (sign(pk, SkMessage(hash.data)), eip155) + +proc recoverKey*(tx: Transaction): Opt[PublicKey] = + ## Recovering key / sender is a costly operation - make sure to reuse the + ## outcome! + ## + ## Returns `none` if the signature is invalid with respect to the rest of + ## the transaction data. + let + sig = ?tx.signature() + txHash = tx.rlpHashForSigning(tx.isEip155()) + + recover(sig, SkMessage(txHash.data)).mapConvertErr(void) + +proc recoverSender*(tx: Transaction): Opt[Address] = + ## Recovering key / sender is a costly operation - make sure to reuse the + ## outcome! + ## + ## Returns `none` if the signature is invalid with respect to the rest of + ## the transaction data. + let key = ?tx.recoverKey() + ok key.to(Address) + +proc creationAddress*(tx: Transaction, sender: Address): Address = + let hash = keccak256(rlp.encodeList(sender, tx.nonce)) + hash.to(Address) + +proc getRecipient*(tx: Transaction, sender: Address): Address = + tx.to.valueOr(tx.creationAddress(sender)) diff --git a/eth/common/transactions.nim b/eth/common/transactions.nim index e6b35dd..5a4c0fb 100644 --- a/eth/common/transactions.nim +++ b/eth/common/transactions.nim @@ -11,6 +11,8 @@ import "."/[addresses, base, hashes] export addresses, base, hashes +const EIP155_CHAIN_ID_OFFSET* = 35'u64 + type AccessPair* = object address* : Address @@ -71,3 +73,9 @@ func destination*(tx: Transaction): Address = # use getRecipient if you also want to get # the contract address tx.to.valueOr(default(Address)) + +func isEip155*(tx: Transaction): bool = + tx.V >= EIP155_CHAIN_ID_OFFSET + +func contractCreation*(tx: Transaction): bool = + tx.to.isNone diff --git a/eth/common/transactions_rlp.nim b/eth/common/transactions_rlp.nim index e130643..92468f5 100644 --- a/eth/common/transactions_rlp.nim +++ b/eth/common/transactions_rlp.nim @@ -128,8 +128,6 @@ proc append*(w: var RlpWriter, tx: PooledTransaction) = if tx.networkPayload != nil: w.append(tx.networkPayload) -const EIP155_CHAIN_ID_OFFSET* = 35'u64 - proc rlpEncodeLegacy(tx: Transaction): seq[byte] = var w = initRlpWriter() w.startList(6) @@ -142,7 +140,6 @@ proc rlpEncodeLegacy(tx: Transaction): seq[byte] = w.finish() proc rlpEncodeEip155(tx: Transaction): seq[byte] = - let chainId = (tx.V - EIP155_CHAIN_ID_OFFSET) div 2 var w = initRlpWriter() w.startList(9) w.append(tx.nonce) @@ -151,7 +148,7 @@ proc rlpEncodeEip155(tx: Transaction): seq[byte] = w.append(tx.to) w.append(tx.value) w.append(tx.payload) - w.append(chainId) + w.append(tx.chainId) w.append(0'u8) w.append(0'u8) w.finish() @@ -218,10 +215,12 @@ proc rlpEncodeEip7702(tx: Transaction): seq[byte] = w.append(tx.authorizationList) w.finish() -proc rlpEncode*(tx: Transaction): seq[byte] = +proc encodeForSigning*(tx: Transaction, eip155: bool): seq[byte] = + ## Encode transaction data in preparation for signing or signature checking. + ## For signature checking, set `eip155 = tx.isEip155` case tx.txType of TxLegacy: - if tx.V >= EIP155_CHAIN_ID_OFFSET: tx.rlpEncodeEip155 else: tx.rlpEncodeLegacy + if eip155: tx.rlpEncodeEip155 else: tx.rlpEncodeLegacy of TxEip2930: tx.rlpEncodeEip2930 of TxEip1559: @@ -231,11 +230,17 @@ proc rlpEncode*(tx: Transaction): seq[byte] = of TxEip7702: tx.rlpEncodeEip7702 -func txHashNoSignature*(tx: Transaction): Hash32 = - # Hash transaction without signature - keccak256(rlpEncode(tx)) +template rlpEncode*(tx: Transaction): seq[byte] {.deprecated.} = + encodeForSigning(tx, tx.isEip155()) -proc readTxLegacy*(rlp: var Rlp, tx: var Transaction) {.raises: [RlpError].} = +func rlpHashForSigning*(tx: Transaction, eip155: bool): Hash32 = + # Hash transaction without signature + keccak256(encodeForSigning(tx, eip155)) + +template txHashNoSignature*(tx: Transaction): Hash32 {.deprecated.} = + rlpHashForSigning(tx, tx.isEip155()) + +proc readTxLegacy(rlp: var Rlp, tx: var Transaction) {.raises: [RlpError].} = tx.txType = TxLegacy rlp.tryEnterList() rlp.read(tx.nonce) @@ -248,6 +253,9 @@ proc readTxLegacy*(rlp: var Rlp, tx: var Transaction) {.raises: [RlpError].} = rlp.read(tx.R) rlp.read(tx.S) + if tx.V >= EIP155_CHAIN_ID_OFFSET: + tx.chainId = ChainId((tx.V - EIP155_CHAIN_ID_OFFSET) div 2) + proc readTxEip2930(rlp: var Rlp, tx: var Transaction) {.raises: [RlpError].} = tx.txType = TxEip2930 rlp.tryEnterList() @@ -375,7 +383,7 @@ proc readTxPayload( of TxEip7702: rlp.readTxEip7702(tx) -proc readTxTyped*(rlp: var Rlp, tx: var Transaction) {.raises: [RlpError].} = +proc readTxTyped(rlp: var Rlp, tx: var Transaction) {.raises: [RlpError].} = let txType = rlp.readTxType() rlp.readTxPayload(tx, txType) diff --git a/tests/common/all_tests.nim b/tests/common/all_tests.nim index fccbf74..10f45af 100644 --- a/tests/common/all_tests.nim +++ b/tests/common/all_tests.nim @@ -10,8 +10,8 @@ import test_common, - test_eip4844, - test_eip7702, test_eth_types, test_eth_types_rlp, - test_keys + test_keys, + test_receipts, + test_transactions diff --git a/tests/common/test_common.nim b/tests/common/test_common.nim index 22d190c..1ac715b 100644 --- a/tests/common/test_common.nim +++ b/tests/common/test_common.nim @@ -17,7 +17,7 @@ type header: Header proc loadFile(x: int) = - let fileName = "tests" / "common" / "eip2718" / "acl_block_" & $x & ".json" + let fileName = currentSourcePath.parentDir / "eip2718" / "acl_block_" & $x & ".json" test fileName: let n = json.parseFile(fileName) let data = n["rlp"].getStr() diff --git a/tests/common/test_eip7702.nim b/tests/common/test_eip7702.nim deleted file mode 100644 index 57f6833..0000000 --- a/tests/common/test_eip7702.nim +++ /dev/null @@ -1,134 +0,0 @@ -# Nimbus -# Copyright (c) 2024 Status Research & Development GmbH -# Licensed under either of -# * Apache License, version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or -# http://www.apache.org/licenses/LICENSE-2.0) -# * MIT license ([LICENSE-MIT](LICENSE-MIT) or -# http://opensource.org/licenses/MIT) -# at your option. This file may not be copied, modified, or distributed except -# according to those terms. -{.used.} - -import - stew/byteutils, - results, - unittest2, - ../../eth/common, - ../../eth/rlp, - ../../eth/common/[keys, transactions_rlp] - -const - recipient = address"095e7baea6a6c7c4c2dfeb977efac326af552d87" - source = address"0x0000000000000000000000000000000000000001" - storageKey= default(Bytes32) - accesses = @[AccessPair(address: source, storageKeys: @[storageKey])] - abcdef = hexToSeqByte("abcdef") - authList = @[Authorization( - chainID: 1.ChainId, - address: source, - nonce: 2.AccountNonce, - yParity: 3, - R: 4.u256, - S: 5.u256 - )] - -proc tx0(i: int): Transaction = - Transaction( - txType: TxEip7702, - chainId: 1.ChainId, - nonce: i.AccountNonce, - maxPriorityFeePerGas: 2.GasInt, - maxFeePerGas: 3.GasInt, - gasLimit: 4.GasInt, - to: Opt.some recipient, - value: 5.u256, - payload: abcdef, - accessList: accesses, - authorizationList: authList - ) - -func `==`(a, b: ChainId): bool = - a.uint64 == b.uint64 - -template roundTrip(txFunc: untyped, i: int) = - let tx = txFunc(i) - let bytes = rlp.encode(tx) - let tx2 = rlp.decode(bytes, Transaction) - let bytes2 = rlp.encode(tx2) - check bytes == bytes2 - -template read[T](rlp: var Rlp, val: var T) = - val = rlp.read(type val) - -proc read[T](rlp: var Rlp, val: var Opt[T]) = - if rlp.blobLen != 0: - val = Opt.some(rlp.read(T)) - else: - rlp.skipElem - -proc readTx(rlp: var Rlp, tx: var Transaction) = - rlp.tryEnterList() - tx.chainId = rlp.read(uint64).ChainId - rlp.read(tx.nonce) - rlp.read(tx.maxPriorityFeePerGas) - rlp.read(tx.maxFeePerGas) - rlp.read(tx.gasLimit) - rlp.read(tx.to) - rlp.read(tx.value) - rlp.read(tx.payload) - rlp.read(tx.accessList) - rlp.read(tx.authorizationList) - -proc decodeTxEip7702(bytes: openArray[byte]): Transaction = - var rlp = rlpFromBytes(bytes) - result.txType = TxType(rlp.getByteValue) - rlp.position += 1 - readTx(rlp, result) - -suite "Transaction EIP-7702 tests": - test "Tx RLP roundtrip": - roundTrip(tx0, 1) - - test "Tx Sign": - const - keyHex = "63b508a03c3b5937ceb903af8b1b0c191012ef6eb7e9c3fb7afa94e5d214d376" - - var - tx = tx0(2) - - let - privateKey = PrivateKey.fromHex(keyHex).expect("valid key") - rlpTx = rlpEncode(tx) - sig = sign(privateKey, rlpTx).toRaw - - tx.V = sig[64].uint64 - tx.R = UInt256.fromBytesBE(sig[0..31]) - tx.S = UInt256.fromBytesBE(sig[32..63]) - - let - bytes = rlp.encode(tx) - decodedTx = rlp.decode(bytes, Transaction) - decodedNoSig = decodeTxEip7702(rlpTx) - - var - expectedTx = tx0(2) - - check expectedTx == decodedNoSig - - expectedTx.V = tx.V - expectedTx.R = tx.R - expectedTx.S = tx.S - - check expectedTx == tx - - test "Receipt RLP roundtrip": - let rec = Receipt( - receiptType: Eip7702Receipt, - isHash: false, - status: false, - cumulativeGasUsed: 100.GasInt) - - let bytes = rlp.encode(rec) - let zz = rlp.decode(bytes, Receipt) - let bytes2 = rlp.encode(zz) - check bytes2 == bytes diff --git a/tests/common/test_eth_types_rlp.nim b/tests/common/test_eth_types_rlp.nim index 391b304..f11cbcb 100644 --- a/tests/common/test_eth_types_rlp.nim +++ b/tests/common/test_eth_types_rlp.nim @@ -296,4 +296,3 @@ suite "EIP-7865 tests": check decodedBody == body check decodedBlk == blk - diff --git a/tests/common/test_receipts.nim b/tests/common/test_receipts.nim new file mode 100644 index 0000000..006a293 --- /dev/null +++ b/tests/common/test_receipts.nim @@ -0,0 +1,39 @@ +# Nimbus +# Copyright (c) 2023-2024 Status Research & Development GmbH +# Licensed under either of +# * Apache License, version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or +# http://www.apache.org/licenses/LICENSE-2.0) +# * MIT license ([LICENSE-MIT](LICENSE-MIT) or +# http://opensource.org/licenses/MIT) +# at your option. This file may not be copied, modified, or distributed except +# according to those terms. +{.used.} + +import + unittest2, + ../../eth/common/[receipts_rlp] + +template roundTrip(v: untyped) = + let bytes = rlp.encode(v) + let v2 = rlp.decode(bytes, Receipt) + let bytes2 = rlp.encode(v2) + check bytes == bytes2 + +suite "Receipts": + test "EIP-4844": + let rec = Receipt( + receiptType: Eip4844Receipt, + isHash: false, + status: false, + cumulativeGasUsed: 100.GasInt) + + roundTrip(rec) + + test "EIP-7702": + let rec = Receipt( + receiptType: Eip7702Receipt, + isHash: false, + status: false, + cumulativeGasUsed: 100.GasInt) + + roundTrip(rec) diff --git a/tests/common/test_eip4844.nim b/tests/common/test_transactions.nim similarity index 74% rename from tests/common/test_eip4844.nim rename to tests/common/test_transactions.nim index 5733528..edfa041 100644 --- a/tests/common/test_eip4844.nim +++ b/tests/common/test_transactions.nim @@ -12,18 +12,24 @@ import stew/byteutils, unittest2, - ../../eth/common, - ../../eth/rlp, - ../../eth/common/transactions_rlp + ../../eth/common/[transactions_rlp, transaction_utils] const recipient = address"095e7baea6a6c7c4c2dfeb977efac326af552d87" zeroG1 = bytes48"0xc00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" source = address"0x0000000000000000000000000000000000000001" - storageKey= default(StorageKey) + storageKey= default(Bytes32) accesses = @[AccessPair(address: source, storageKeys: @[storageKey])] blob = default(NetworkBlob) abcdef = hexToSeqByte("abcdef") + authList = @[Authorization( + chainID: 1.ChainId, + address: source, + nonce: 2.AccountNonce, + yParity: 3, + R: 4.u256, + S: 5.u256 + )] proc tx0(i: int): PooledTransaction = PooledTransaction( @@ -144,6 +150,23 @@ proc tx8(i: int): PooledTransaction = versionedHashes: @[digest], maxFeePerBlobGas: 10000000.u256)) +proc txEip7702(i: int): PooledTransaction = + PooledTransaction( + tx: Transaction( + txType: TxEip7702, + chainId: 1.ChainId, + nonce: i.AccountNonce, + maxPriorityFeePerGas: 2.GasInt, + maxFeePerGas: 3.GasInt, + gasLimit: 4.GasInt, + to: Opt.some recipient, + value: 5.u256, + payload: abcdef, + accessList: accesses, + authorizationList: authList + ) + ) + template roundTrip(txFunc: untyped, i: int) = let tx = txFunc(i) let bytes = rlp.encode(tx) @@ -151,7 +174,7 @@ template roundTrip(txFunc: untyped, i: int) = let bytes2 = rlp.encode(tx2) check bytes == bytes2 -suite "Transaction RLP Encoding": +suite "Transactions": test "Legacy Tx Call": roundTrip(tx0, 1) @@ -179,6 +202,9 @@ suite "Transaction RLP Encoding": test "Minimal Blob Tx contract creation": roundTrip(tx8, 9) + test "EIP 7702": + roundTrip(txEip7702, 9) + test "Network payload survive encode decode": let tx = tx6(10) let bytes = rlp.encode(tx) @@ -227,14 +253,47 @@ suite "Transaction RLP Encoding": let bytes2 = rlp.encode(zz) check bytes2 == bytes - test "Receipts": - let rec = Receipt( - receiptType: Eip4844Receipt, - isHash: false, - status: false, - cumulativeGasUsed: 100.GasInt) + test "EIP-155 signature": + # https://github.com/ethereum/EIPs/blob/master/EIPS/eip-155.md#example + var + tx = Transaction( + txType: TxLegacy, + chainId: ChainId(1), + nonce: 9, + gasPrice: 20000000000'u64, + gasLimit: 21000'u64, + to: Opt.some address"0x3535353535353535353535353535353535353535", + value: u256"1000000000000000000", + ) + txEnc = tx.encodeForSigning(true) + txHash = tx.rlpHashForSigning(true) + key = PrivateKey.fromHex("0x4646464646464646464646464646464646464646464646464646464646464646").expect( + "working key" + ) - let bytes = rlp.encode(rec) - let zz = rlp.decode(bytes, Receipt) - let bytes2 = rlp.encode(zz) - check bytes2 == bytes + tx.signature = tx.sign(key, true) + + check: + txEnc.to0xHex == "0xec098504a817c800825208943535353535353535353535353535353535353535880de0b6b3a764000080018080" + txHash == hash32"0xdaf5a779ae972f972197303d7b574746c7ef83eadac0f2791ad23db92e4c8e53" + tx.V == 37 + tx.R == + u256"18515461264373351373200002665853028612451056578545711640558177340181847433846" + tx.S == + u256"46948507304638947509940763649030358759909902576025900602547168820602576006531" + + test "sign transaction": + let + txs = @[ + tx0(3).tx, tx1(3).tx, tx2(3).tx, tx3(3).tx, tx4(3).tx, + tx5(3).tx, tx6(3).tx, tx7(3).tx, tx8(3).tx, txEip7702(3).tx] + + privKey = PrivateKey.fromHex("63b508a03c3b5937ceb903af8b1b0c191012ef6eb7e9c3fb7afa94e5d214d376").expect("valid key") + sender = privKey.toPublicKey().to(Address) + + for tx in txs: + var tx = tx + tx.signature = tx.sign(privKey, true) + + check: + tx.recoverKey().expect("valid key").to(Address) == sender