mirror of https://github.com/status-im/nim-eth.git
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:
parent
84664b0fc0
commit
4ea11b9fb9
|
@ -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:
|
||||
|
|
|
@ -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))
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
|
@ -296,4 +296,3 @@ suite "EIP-7865 tests":
|
|||
|
||||
check decodedBody == body
|
||||
check decodedBlk == blk
|
||||
|
||||
|
|
|
@ -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)
|
|
@ -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
|
Loading…
Reference in New Issue