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.
This commit is contained in:
Jacek Sieka 2024-10-04 13:46:58 +02:00 committed by GitHub
parent 84664b0fc0
commit 4ea11b9fb9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 221 additions and 168 deletions

View File

@ -41,9 +41,6 @@ type
EthReceipt* = Receipt EthReceipt* = Receipt
EthWithdrawapRequest* = WithdrawalRequest EthWithdrawapRequest* = WithdrawalRequest
template contractCreation*(tx: Transaction): bool =
tx.to.isNone
func init*(T: type BlockHashOrNumber, str: string): T {.raises: [ValueError].} = func init*(T: type BlockHashOrNumber, str: string): T {.raises: [ValueError].} =
if str.startsWith "0x": if str.startsWith "0x":
if str.len != sizeof(default(T).hash.data) * 2 + 2: if str.len != sizeof(default(T).hash.data) * 2 + 2:

View File

@ -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))

View File

@ -11,6 +11,8 @@ import "."/[addresses, base, hashes]
export addresses, base, hashes export addresses, base, hashes
const EIP155_CHAIN_ID_OFFSET* = 35'u64
type type
AccessPair* = object AccessPair* = object
address* : Address address* : Address
@ -71,3 +73,9 @@ func destination*(tx: Transaction): Address =
# use getRecipient if you also want to get # use getRecipient if you also want to get
# the contract address # the contract address
tx.to.valueOr(default(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

View File

@ -128,8 +128,6 @@ proc append*(w: var RlpWriter, tx: PooledTransaction) =
if tx.networkPayload != nil: if tx.networkPayload != nil:
w.append(tx.networkPayload) w.append(tx.networkPayload)
const EIP155_CHAIN_ID_OFFSET* = 35'u64
proc rlpEncodeLegacy(tx: Transaction): seq[byte] = proc rlpEncodeLegacy(tx: Transaction): seq[byte] =
var w = initRlpWriter() var w = initRlpWriter()
w.startList(6) w.startList(6)
@ -142,7 +140,6 @@ proc rlpEncodeLegacy(tx: Transaction): seq[byte] =
w.finish() w.finish()
proc rlpEncodeEip155(tx: Transaction): seq[byte] = proc rlpEncodeEip155(tx: Transaction): seq[byte] =
let chainId = (tx.V - EIP155_CHAIN_ID_OFFSET) div 2
var w = initRlpWriter() var w = initRlpWriter()
w.startList(9) w.startList(9)
w.append(tx.nonce) w.append(tx.nonce)
@ -151,7 +148,7 @@ proc rlpEncodeEip155(tx: Transaction): seq[byte] =
w.append(tx.to) w.append(tx.to)
w.append(tx.value) w.append(tx.value)
w.append(tx.payload) w.append(tx.payload)
w.append(chainId) w.append(tx.chainId)
w.append(0'u8) w.append(0'u8)
w.append(0'u8) w.append(0'u8)
w.finish() w.finish()
@ -218,10 +215,12 @@ proc rlpEncodeEip7702(tx: Transaction): seq[byte] =
w.append(tx.authorizationList) w.append(tx.authorizationList)
w.finish() 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 case tx.txType
of TxLegacy: of TxLegacy:
if tx.V >= EIP155_CHAIN_ID_OFFSET: tx.rlpEncodeEip155 else: tx.rlpEncodeLegacy if eip155: tx.rlpEncodeEip155 else: tx.rlpEncodeLegacy
of TxEip2930: of TxEip2930:
tx.rlpEncodeEip2930 tx.rlpEncodeEip2930
of TxEip1559: of TxEip1559:
@ -231,11 +230,17 @@ proc rlpEncode*(tx: Transaction): seq[byte] =
of TxEip7702: of TxEip7702:
tx.rlpEncodeEip7702 tx.rlpEncodeEip7702
func txHashNoSignature*(tx: Transaction): Hash32 = template rlpEncode*(tx: Transaction): seq[byte] {.deprecated.} =
# Hash transaction without signature encodeForSigning(tx, tx.isEip155())
keccak256(rlpEncode(tx))
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 tx.txType = TxLegacy
rlp.tryEnterList() rlp.tryEnterList()
rlp.read(tx.nonce) 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.R)
rlp.read(tx.S) 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].} = proc readTxEip2930(rlp: var Rlp, tx: var Transaction) {.raises: [RlpError].} =
tx.txType = TxEip2930 tx.txType = TxEip2930
rlp.tryEnterList() rlp.tryEnterList()
@ -375,7 +383,7 @@ proc readTxPayload(
of TxEip7702: of TxEip7702:
rlp.readTxEip7702(tx) 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() let txType = rlp.readTxType()
rlp.readTxPayload(tx, txType) rlp.readTxPayload(tx, txType)

View File

@ -10,8 +10,8 @@
import import
test_common, test_common,
test_eip4844,
test_eip7702,
test_eth_types, test_eth_types,
test_eth_types_rlp, test_eth_types_rlp,
test_keys test_keys,
test_receipts,
test_transactions

View File

@ -17,7 +17,7 @@ type
header: Header header: Header
proc loadFile(x: int) = proc loadFile(x: int) =
let fileName = "tests" / "common" / "eip2718" / "acl_block_" & $x & ".json" let fileName = currentSourcePath.parentDir / "eip2718" / "acl_block_" & $x & ".json"
test fileName: test fileName:
let n = json.parseFile(fileName) let n = json.parseFile(fileName)
let data = n["rlp"].getStr() let data = n["rlp"].getStr()

View File

@ -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

View File

@ -296,4 +296,3 @@ suite "EIP-7865 tests":
check decodedBody == body check decodedBody == body
check decodedBlk == blk check decodedBlk == blk

View File

@ -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)

View File

@ -12,18 +12,24 @@
import import
stew/byteutils, stew/byteutils,
unittest2, unittest2,
../../eth/common, ../../eth/common/[transactions_rlp, transaction_utils]
../../eth/rlp,
../../eth/common/transactions_rlp
const const
recipient = address"095e7baea6a6c7c4c2dfeb977efac326af552d87" recipient = address"095e7baea6a6c7c4c2dfeb977efac326af552d87"
zeroG1 = bytes48"0xc00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" zeroG1 = bytes48"0xc00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
source = address"0x0000000000000000000000000000000000000001" source = address"0x0000000000000000000000000000000000000001"
storageKey= default(StorageKey) storageKey= default(Bytes32)
accesses = @[AccessPair(address: source, storageKeys: @[storageKey])] accesses = @[AccessPair(address: source, storageKeys: @[storageKey])]
blob = default(NetworkBlob) blob = default(NetworkBlob)
abcdef = hexToSeqByte("abcdef") 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 = proc tx0(i: int): PooledTransaction =
PooledTransaction( PooledTransaction(
@ -144,6 +150,23 @@ proc tx8(i: int): PooledTransaction =
versionedHashes: @[digest], versionedHashes: @[digest],
maxFeePerBlobGas: 10000000.u256)) 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) = template roundTrip(txFunc: untyped, i: int) =
let tx = txFunc(i) let tx = txFunc(i)
let bytes = rlp.encode(tx) let bytes = rlp.encode(tx)
@ -151,7 +174,7 @@ template roundTrip(txFunc: untyped, i: int) =
let bytes2 = rlp.encode(tx2) let bytes2 = rlp.encode(tx2)
check bytes == bytes2 check bytes == bytes2
suite "Transaction RLP Encoding": suite "Transactions":
test "Legacy Tx Call": test "Legacy Tx Call":
roundTrip(tx0, 1) roundTrip(tx0, 1)
@ -179,6 +202,9 @@ suite "Transaction RLP Encoding":
test "Minimal Blob Tx contract creation": test "Minimal Blob Tx contract creation":
roundTrip(tx8, 9) roundTrip(tx8, 9)
test "EIP 7702":
roundTrip(txEip7702, 9)
test "Network payload survive encode decode": test "Network payload survive encode decode":
let tx = tx6(10) let tx = tx6(10)
let bytes = rlp.encode(tx) let bytes = rlp.encode(tx)
@ -227,14 +253,47 @@ suite "Transaction RLP Encoding":
let bytes2 = rlp.encode(zz) let bytes2 = rlp.encode(zz)
check bytes2 == bytes check bytes2 == bytes
test "Receipts": test "EIP-155 signature":
let rec = Receipt( # https://github.com/ethereum/EIPs/blob/master/EIPS/eip-155.md#example
receiptType: Eip4844Receipt, var
isHash: false, tx = Transaction(
status: false, txType: TxLegacy,
cumulativeGasUsed: 100.GasInt) 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) tx.signature = tx.sign(key, true)
let zz = rlp.decode(bytes, Receipt)
let bytes2 = rlp.encode(zz) check:
check bytes2 == bytes 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