diff --git a/lib/eth/tx.rb b/lib/eth/tx.rb index 3617f8bc..21b5639f 100644 --- a/lib/eth/tx.rb +++ b/lib/eth/tx.rb @@ -17,6 +17,7 @@ require "eth/chain" require "eth/tx/eip1559" require "eth/tx/eip2930" +require "eth/tx/eip7702" require "eth/tx/legacy" require "eth/unit" @@ -72,6 +73,9 @@ class ParameterError < TypeError; end # The EIP-1559 transaction type is 2. TYPE_1559 = 0x02.freeze + # The EIP-7702 transaction type is 4. + TYPE_7702 = 0x04.freeze + # The zero byte is 0x00. ZERO_BYTE = "\x00".freeze @@ -80,6 +84,8 @@ class ParameterError < TypeError; end # Creates a new transaction of any type for given parameters and chain ID. # Required parameters are (optional in brackets): + # - EIP-7702: chain_id, nonce, priority_fee, max_gas_fee, gas_limit, authorizations(, from, to, + # value, data, access_list) # - EIP-1559: chain_id, nonce, priority_fee, max_gas_fee, gas_limit(, from, to, # value, data, access_list) # - EIP-2930: chain_id, nonce, gas_price, gas_limit, access_list(, from, to, @@ -90,6 +96,12 @@ class ParameterError < TypeError; end # @param chain_id [Integer] the EIP-155 Chain ID (legacy transactions only). def new(params, chain_id = Chain::ETHEREUM) + # if we deal with authorizations, attempt EIP-7702 + unless params[:authorization_list].nil? + params[:chain_id] = chain_id if params[:chain_id].nil? + return Tx::Eip7702.new params + end + # if we deal with max gas fee parameter, attempt EIP-1559 unless params[:max_gas_fee].nil? params[:chain_id] = chain_id if params[:chain_id].nil? @@ -114,8 +126,14 @@ def new(params, chain_id = Chain::ETHEREUM) # @raise [TransactionTypeError] if the transaction type is unknown. def decode(hex) hex = Util.remove_hex_prefix hex + puts hex[0, 2].to_i(16) type = hex[0, 2].to_i(16) + case type + when TYPE_7702 + + # EIP-1559 transaction (type 2) + return Tx::Eip7702.decode hex when TYPE_1559 # EIP-1559 transaction (type 2) @@ -142,6 +160,8 @@ def decode(hex) # @raise [TransactionTypeError] if the transaction type is unknown. def unsigned_copy(tx) case tx.type + when TYPE_7702 + return Tx::Eip7702.unsigned_copy tx when TYPE_1559 # EIP-1559 transaction (type 2) diff --git a/lib/eth/tx/eip7702.rb b/lib/eth/tx/eip7702.rb new file mode 100644 index 00000000..d66c993c --- /dev/null +++ b/lib/eth/tx/eip7702.rb @@ -0,0 +1,474 @@ +# Copyright (c) 2016-2025 The Ruby-Eth Contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Provides the {Eth} module. +module Eth + + # Provides the `Tx` module supporting various transaction types. + module Tx + + # Provides support for EIP-7702 transactions utilizing EIP-2718 + # types and envelopes. + # Ref: https://eips.ethereum.org/EIPS/eip-7702 + class Eip7702 + class Authorization + attr_reader :chain_id + + attr_reader :address + + attr_reader :nonce + + # The signature's y-parity byte (not v). + attr_reader :signature_y_parity + + # The signature `r` value. + attr_reader :signature_r + + # The signature `s` value. + attr_reader :signature_s + + def initialize(fields) + @chain_id = fields[:chain_id].to_i + @address = fields[:address].to_s + @nonce = fields[:nonce].to_i + @signature_y_parity = fields[:recovery_id] + @signature_r = fields[:r] + @signature_s = fields[:s] + end + + # Sign the authorization with a given key. + # + # @param key [Eth::Key] the key-pair to use for signing. + # @return [String] a transaction hash. + # @raise [Signature::SignatureError] if authorization is already signed. + # @raise [Signature::SignatureError] if sender address does not match signing key. + def sign(key) + if Tx.signed? self + raise Signature::SignatureError, "Authorization is already signed!" + end + + # ensure the sender address matches the given key + unless @address.nil? or @address.empty? + signer_address = Tx.sanitize_address key.address.to_s + from_address = Tx.sanitize_address @address + raise Signature::SignatureError, "Signer does not match sender" unless signer_address == from_address + end + + # sign a keccak hash of the unsigned, encoded transaction + signature = key.sign(unsigned_hash, @chain_id) + r, s, v = Signature.dissect signature + recovery_id = Chain.to_recovery_id v.to_i(16), @chain_id + @signature_y_parity = recovery_id + @signature_r = r + @signature_s = s + return hash + end + + def unsigned_encoded + authorization_data = [] + authorization_data.push Util.serialize_int_to_big_endian @chain_id + authorization_data.push Util.hex_to_bin @address + authorization_data.push Util.serialize_int_to_big_endian @nonce + Rlp.encode authorization_data + end + + def unsigned_hash + Util.keccak256 unsigned_encoded + end + + def raw + authorization_data = [] + authorization_data.push Util.serialize_int_to_big_endian @chain_id + authorization_data.push Util.hex_to_bin @address + authorization_data.push Util.serialize_int_to_big_endian @nonce + + authorization_data.push Util.serialize_int_to_big_endian @signature_y_parity + authorization_data.push Util.serialize_int_to_big_endian @signature_r + authorization_data.push Util.serialize_int_to_big_endian @signature_s + authorization_data + end + + def encoded + Rlp.encode raw + end + + def ==(o) + o.class == self.class && o.state == state + end + + protected + + def state + [@chain_id, @address, @nonce, @signature_y_parity, @signature_r, @signature_s] + end + end + + # The EIP-155 Chain ID. + # Ref: https://eips.ethereum.org/EIPS/eip-155 + attr_reader :chain_id + + # The transaction nonce provided by the signer. + attr_reader :signer_nonce + + # The transaction max priority fee per gas in Wei. + attr_reader :max_priority_fee_per_gas + + # The transaction max fee per gas in Wei. + attr_reader :max_fee_per_gas + + # The gas limit for the transaction. + attr_reader :gas_limit + + # The recipient address. + attr_reader :destination + + # The transaction amount in Wei. + attr_reader :amount + + # The transaction data payload. + attr_reader :payload + + # An optional EIP-2930 access list. + # Ref: https://eips.ethereum.org/EIPS/eip-2930 + attr_reader :access_list + + # The list of authorizations (a list of Eth::Tx::Eip7702::Authorization instances) + attr_reader :authorization_list + + # The signature's y-parity byte (not v). + attr_reader :signature_y_parity + + # The signature `r` value. + attr_reader :signature_r + + # The signature `s` value. + attr_reader :signature_s + + # The sender address. + attr_reader :sender + + # The transaction type. + attr_reader :type + + # Create a type-4 (EIP-7702) transaction payload object that + # can be prepared for envelope, signature and broadcast. + # Ref: https://eips.ethereum.org/EIPS/eip-7702 + # + # @param params [Hash] all necessary transaction fields. + # @option params [Integer] :chain_id the chain ID. + # @option params [Integer] :nonce the signer nonce. + # @option params [Integer] :priority_fee the max priority fee per gas. + # @option params [Integer] :max_gas_fee the max transaction fee per gas. + # @option params [Integer] :gas_limit the gas limit. + # @option params [Eth::Address] :from the sender address. + # @option params [Eth::Address] :to the reciever address. + # @option params [Integer] :value the transaction value. + # @option params [String] :data the transaction data payload. + # @option params [Array] :access_list an optional access list. + # @option params [Array] :authorization_list the list of authorization instances (a list of Eth::Tx::Eip7702::Authorization instances). + # @raise [ParameterError] if gas limit is too low. + def initialize(params) + fields = { recovery_id: nil, r: 0, s: 0 }.merge params + + # populate optional fields with serializable empty values + fields[:chain_id] = Tx.sanitize_chain fields[:chain_id] + fields[:from] = Tx.sanitize_address fields[:from] + fields[:to] = Tx.sanitize_address fields[:to] + fields[:value] = Tx.sanitize_amount fields[:value] + fields[:data] = Tx.sanitize_data fields[:data] + + # ensure sane values for all mandatory fields + fields = Tx.validate_params fields + fields = Tx.validate_eip1559_params fields + # TODO fields = Tx.validate_eip7702_params fields + fields[:access_list] = Tx.sanitize_list fields[:access_list] + + # ensure gas limit is not too low + minimum_cost = Tx.estimate_intrinsic_gas fields[:data], fields[:access_list] + raise ParameterError, "Transaction gas limit is too low, try #{minimum_cost}!" if fields[:gas_limit].to_i < minimum_cost + + # populate class attributes + @signer_nonce = fields[:nonce].to_i + @max_priority_fee_per_gas = fields[:priority_fee].to_i + @max_fee_per_gas = fields[:max_gas_fee].to_i + @gas_limit = fields[:gas_limit].to_i + @sender = fields[:from].to_s + @destination = fields[:to].to_s + @amount = fields[:value].to_i + @payload = fields[:data] + @access_list = fields[:access_list] + @authorization_list = fields[:authorization_list] + + # the signature v is set to the chain id for unsigned transactions + @signature_y_parity = fields[:recovery_id] + @chain_id = fields[:chain_id] + + # the signature fields are empty for unsigned transactions. + @signature_r = fields[:r] + @signature_s = fields[:s] + + # last but not least, set the type. + @type = TYPE_7702 + end + + # Overloads the constructor for decoding raw transactions and creating unsigned copies. + konstructor :decode, :unsigned_copy + + # Decodes a raw transaction hex into an {Eth::Tx::Eip7702} + # transaction object. + # + # @param hex [String] the raw transaction hex-string. + # @return [Eth::Tx::Eip7702] transaction payload. + # @raise [TransactionTypeError] if transaction type is invalid. + # @raise [ParameterError] if transaction is missing fields. + # @raise [DecoderError] if transaction decoding fails. + def decode(hex) + hex = Util.remove_hex_prefix hex + type = hex[0, 2] + raise TransactionTypeError, "Invalid transaction type #{type.inspect}!" if type.to_i(16) != TYPE_7702 + + bin = Util.hex_to_bin hex[2..] + tx = Rlp.decode bin + puts tx.inspect + + # decoded transactions always have 9 + 3 fields, even if they are empty or zero + raise ParameterError, "Transaction missing fields!" if tx.size < 10 + + # populate the 10 payload fields + chain_id = Util.deserialize_big_endian_to_int tx[0] + nonce = Util.deserialize_big_endian_to_int tx[1] + priority_fee = Util.deserialize_big_endian_to_int tx[2] + max_gas_fee = Util.deserialize_big_endian_to_int tx[3] + gas_limit = Util.deserialize_big_endian_to_int tx[4] + to = Util.bin_to_hex tx[5] + value = Util.deserialize_big_endian_to_int tx[6] + data = tx[7] + access_list = tx[8] + authorization_list = tx[9] + authorizations = deserialize_authorizations(authorization_list) + + # populate class attributes + @chain_id = chain_id.to_i + @signer_nonce = nonce.to_i + @max_priority_fee_per_gas = priority_fee.to_i + @max_fee_per_gas = max_gas_fee.to_i + @gas_limit = gas_limit.to_i + @destination = to.to_s + @amount = value.to_i + @payload = data + @access_list = access_list + @authorization_list = authorizations + + # populate the 3 signature fields + if tx.size == 10 + _set_signature(nil, 0, 0) + elsif tx.size == 13 + recovery_id = Util.bin_to_hex(tx[10]).to_i(16) + r = Util.bin_to_hex tx[11] + s = Util.bin_to_hex tx[12] + + # allows us to force-setting a signature if the transaction is signed already + _set_signature(recovery_id, r, s) + else + raise DecoderError, "Cannot decode EIP-7702 payload!" + end + + # last but not least, set the type. + @type = TYPE_7702 + + unless recovery_id.nil? + # recover sender address + v = Chain.to_v recovery_id, chain_id + public_key = Signature.recover(unsigned_hash, "#{r.rjust(64, "0")}#{s.rjust(64, "0")}#{v.to_s(16)}", chain_id) + address = Util.public_key_to_address(public_key).to_s + @sender = Tx.sanitize_address address + else + # keep the 'from' field blank + @sender = Tx.sanitize_address nil + end + end + + # Creates an unsigned copy of a transaction payload (keeping the signatures on the authorizations). + # + # @param tx [Eth::Tx::Eip7702] an EIP-7702 transaction payload. + # @return [Eth::Tx::Eip7702] an unsigned EIP-7702 transaction payload. + # @raise [TransactionTypeError] if transaction type does not match. + def unsigned_copy(tx) + + # not checking transaction validity unless it's of a different class + raise TransactionTypeError, "Cannot copy transaction of different payload type!" unless tx.instance_of? Tx::Eip7702 + + # populate class attributes + @signer_nonce = tx.signer_nonce + @max_priority_fee_per_gas = tx.max_priority_fee_per_gas + @max_fee_per_gas = tx.max_fee_per_gas + @gas_limit = tx.gas_limit + @destination = tx.destination + @amount = tx.amount + @payload = tx.payload + @access_list = tx.access_list + @chain_id = tx.chain_id + + @authorization_list = tx.authorization_list.map do |authorization| + Authorization.new(chain_id: authorization.chain_id, + address: authorization.address, + nonce: authorization.nonce, + recovery_id: authorization.signature_y_parity, + r: authorization.signature_r, + s: authorization.signature_s) + end + # force-set signature to unsigned + _set_signature(nil, 0, 0) + + # keep the 'from' field blank + @sender = Tx.sanitize_address nil + + # last but not least, set the type. + @type = TYPE_7702 + end + + # Sign the transaction with a given key. + # + # @param key [Eth::Key] the key-pair to use for signing. + # @return [String] a transaction hash. + # @raise [Signature::SignatureError] if transaction is already signed. + # @raise [Signature::SignatureError] if sender address does not match signing key. + def sign(key) + if Tx.signed? self + raise Signature::SignatureError, "Transaction is already signed!" + end + + # ensure the sender address matches the given key + unless @sender.nil? or sender.empty? + signer_address = Tx.sanitize_address key.address.to_s + from_address = Tx.sanitize_address @sender + raise Signature::SignatureError, "Signer does not match sender" unless signer_address == from_address + end + + # sign a keccak hash of the unsigned, encoded transaction + signature = key.sign(unsigned_hash, @chain_id) + r, s, v = Signature.dissect signature + recovery_id = Chain.to_recovery_id v.to_i(16), @chain_id + @signature_y_parity = recovery_id + @signature_r = r + @signature_s = s + return hash + end + + # Encodes a raw transaction object, wraps it in an EIP-2718 envelope + # with an EIP-7702 type prefix. + # + # @return [String] a raw, RLP-encoded EIP-7702 type transaction object. + # @raise [Signature::SignatureError] if the transaction is not yet signed. + def encoded + unless Tx.signed? self + raise Signature::SignatureError, "Transaction is not signed!" + end + tx_data = [] + tx_data.push Util.serialize_int_to_big_endian @chain_id + tx_data.push Util.serialize_int_to_big_endian @signer_nonce + tx_data.push Util.serialize_int_to_big_endian @max_priority_fee_per_gas + tx_data.push Util.serialize_int_to_big_endian @max_fee_per_gas + tx_data.push Util.serialize_int_to_big_endian @gas_limit + tx_data.push Util.hex_to_bin @destination + tx_data.push Util.serialize_int_to_big_endian @amount + tx_data.push Rlp::Sedes.binary.serialize @payload + tx_data.push Rlp::Sedes.infer(@access_list).serialize @access_list + + #TODO make the authorization_list right + authorization_list = @authorization_list.map { |authorization| authorization.raw } + tx_data.push Rlp::Sedes.infer(authorization_list).serialize authorization_list #TODO this might need an extra Rpl.encode here + + tx_data.push Util.serialize_int_to_big_endian @signature_y_parity + tx_data.push Util.serialize_int_to_big_endian @signature_r + tx_data.push Util.serialize_int_to_big_endian @signature_s + tx_encoded = Rlp.encode tx_data + + # create an EIP-2718 envelope with EIP-7702 type payload + tx_type = Util.serialize_int_to_big_endian @type + return "#{tx_type}#{tx_encoded}" + end + + # Gets the encoded, enveloped, raw transaction hex. + # + # @return [String] the raw transaction hex. + def hex + Util.bin_to_hex encoded + end + + # Gets the transaction hash. + # + # @return [String] the transaction hash. + def hash + Util.bin_to_hex Util.keccak256 encoded + end + + # Encodes the unsigned transaction payload in an EIP-7702 envelope, + # required for signing. + # + # @return [String] an RLP-encoded, unsigned, enveloped EIP-7702 transaction. + def unsigned_encoded + tx_data = [] + tx_data.push Util.serialize_int_to_big_endian @chain_id + tx_data.push Util.serialize_int_to_big_endian @signer_nonce + tx_data.push Util.serialize_int_to_big_endian @max_priority_fee_per_gas + tx_data.push Util.serialize_int_to_big_endian @max_fee_per_gas + tx_data.push Util.serialize_int_to_big_endian @gas_limit + tx_data.push Util.hex_to_bin @destination + tx_data.push Util.serialize_int_to_big_endian @amount + tx_data.push Rlp::Sedes.binary.serialize @payload + tx_data.push Rlp::Sedes.infer(@access_list).serialize @access_list + + #TODO make the authorization_list right + authorization_list = @authorization_list.map { |authorization| authorization.encoded } + tx_data.push Rlp::Sedes.infer(authorization_list).serialize authorization_list + + tx_encoded = Rlp.encode tx_data + + # create an EIP-2718 envelope with EIP-7702 type payload (unsigned) + tx_type = Util.serialize_int_to_big_endian @type + + return "#{tx_type}#{tx_encoded}" + end + + # Gets the sign-hash required to sign a raw transaction. + # + # @return [String] a Keccak-256 hash of an unsigned transaction. + def unsigned_hash + Util.keccak256 unsigned_encoded + end + + private + + def deserialize_authorizations(authorization_list) + authorization_list.map do |authorization_tuple| + chain_id = Util.deserialize_big_endian_to_int authorization_tuple[0] + address = Util.bin_to_hex authorization_tuple[1] + nonce = Util.deserialize_big_endian_to_int authorization_tuple[2] + recovery_id = Util.bin_to_hex(authorization_tuple[3]).to_i(16) + r = Util.bin_to_hex authorization_tuple[4] + s = Util.bin_to_hex authorization_tuple[5] + Authorization.new(chain_id: chain_id, address: address, nonce: nonce, recovery_id: recovery_id, r: r, s: s) + end + end + + # Force-sets an existing signature of a decoded transaction. + def _set_signature(recovery_id, r, s) + @signature_y_parity = recovery_id + @signature_r = r + @signature_s = s + end + end + end +end diff --git a/spec/eth/tx/eip7702_spec.rb b/spec/eth/tx/eip7702_spec.rb new file mode 100644 index 00000000..4faf2b33 --- /dev/null +++ b/spec/eth/tx/eip7702_spec.rb @@ -0,0 +1,291 @@ +# -*- encoding : ascii-8bit -*- + +require "spec_helper" + +describe Tx::Eip7702 do + subject(:anvil) { + 31337.freeze + } + + subject(:authorization_list) { + [ + Tx::Eip7702::Authorization.new( + chain_id: anvil, + address: "700b6a60ce7eaaea56f065753d8dcb9653dbad35", + nonce: 2, + recovery_id: 0, + r: "a4f2c5243c3d6d82168ef35b3d3df1e50cefee1bc212c769bd1968061c395260", + s: "7f346c1804300b96d687a90ce5bcea0883c12bc45b6a8a294e29ff7c02b42a65", + ), + Tx::Eip7702::Authorization.new( + chain_id: Chain::ETHEREUM, + address: "700b6a60ce7eaaea56f065753d8dcb9653dbad35", + nonce: 11, + r: "acec76e844690cf2f58317d13d910b270cf0b9e307db8094402dc46b4f456a81", + s: "570d6ea163a505896aa2674d56810033cd4d03b13787065b5abe57cde485e52a", + recovery_id: 0, + ), + ] + } + + subject(:unsigned_authorization) { + Tx::Eip7702::Authorization.new( + chain_id: anvil, + address: "700b6a60ce7eaaea56f065753d8dcb9653dbad35", + nonce: 2, + ) + } + + subject(:unsigned_cow_authorization) { + Tx::Eip7702::Authorization.new( + chain_id: anvil, + address: cow.address.to_s, + nonce: 2, + ) + } + subject(:access_list) { + [ + [ + "de0b295669a9fd93d5f28d9ec85e40f4cb697bae", + [ + "0000000000000000000000000000000000000000000000000000000000000003", + "0000000000000000000000000000000000000000000000000000000000000007", + ], + ], + [ + "0xa0ee7a142d267c1f36714e4a8f75612f20a79720", + [], + ], + [ + "0xcb98643b8786950f0461f3b0edf99d88f274574d", + [], + ], + [ + "0xd2135cfb216b74109775236e36d4b433f1df507b", + [], + ], + [ + "0x700b6a60ce7eaaea56f065753d8dcb9653dbad35", + [], + ], + ] + } + + subject(:type04) { + Tx.new({ + chain_id: anvil, + nonce: 1, + priority_fee: 1000000000, + max_gas_fee: 2200000000, + gas_limit: 554330, + to: "0xa0ee7a142d267c1f36714e4a8f75612f20a79720", + value: 0, + data: "0xa6d0ad6100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000060000000000000000000000000cb98643b8786950f0461f3b0edf99d88f274574d00000000000000000000000000000000000000000000000000038d7ea4c6800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000000000000d2135cfb216b74109775236e36d4b433f1df507b00000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000000000000", + access_list: access_list, + authorization_list: authorization_list, + }) + } + + subject(:signed) { + tx = Tx.new({ + chain_id: anvil, + nonce: 1, + priority_fee: 1000000000, + max_gas_fee: 2200000000, + gas_limit: 554330, + to: "0xa0ee7a142d267c1f36714e4a8f75612f20a79720", + value: 0, + data: "0xa6d0ad6100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000060000000000000000000000000cb98643b8786950f0461f3b0edf99d88f274574d00000000000000000000000000000000000000000000000000038d7ea4c6800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000000000000d2135cfb216b74109775236e36d4b433f1df507b00000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000000000000", + access_list: access_list, + authorization_list: authorization_list, + }) + tx.sign(cow) + tx + } + subject(:testnet) { Key.new(priv: "0xc6c633f85d3f9a4705623b1d9bd1122a1a9196cd53dd352505e895fcbb8452ef") } + + subject(:tx) { + Tx.new({ + nonce: 0, + priority_fee: 0, + max_gas_fee: Unit::WEI, + gas_limit: Tx::DEFAULT_GAS_LIMIT, + authorization_list: authorization_list, + }) + } + + subject(:cow) { Key.new(priv: Util.keccak256("cow")) } + subject(:dog) { Key.new(priv: Util.keccak256("dog")) } + subject(:dog_tx) { + tx = Tx.new({ + chain_id: anvil, + nonce: 1, + priority_fee: 1000000000, + max_gas_fee: 2200000000, + gas_limit: 554330, + from: dog.address, + to: "0xa0ee7a142d267c1f36714e4a8f75612f20a79720", + value: 0, + data: "0xa6d0ad6100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000060000000000000000000000000cb98643b8786950f0461f3b0edf99d88f274574d00000000000000000000000000000000000000000000000000038d7ea4c6800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000000000000d2135cfb216b74109775236e36d4b433f1df507b00000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000000000000", + access_list: access_list, + authorization_list: authorization_list, + }) + } + subject(:type04_hex) { + "\x4\xf9\x1\xa\x1\x80\x80\x1\x82\x52\x8\x80\x80\x80\xc0\xf8\xba\xf8\x5c\x82\x7a\x69\x94\x70\xb\x6a\x60\xce\x7e\xaa\xea\x56\xf0\x65\x75\x3d\x8d\xcb\x96\x53\xdb\xad\x35\x2\x80\xa0\xa4\xf2\xc5\x24\x3c\x3d\x6d\x82\x16\x8e\xf3\x5b\x3d\x3d\xf1\xe5\xc\xef\xee\x1b\xc2\x12\xc7\x69\xbd\x19\x68\x6\x1c\x39\x52\x60\xa0\x7f\x34\x6c\x18\x4\x30\xb\x96\xd6\x87\xa9\xc\xe5\xbc\xea\x8\x83\xc1\x2b\xc4\x5b\x6a\x8a\x29\x4e\x29\xff\x7c\x2\xb4\x2a\x65\xf8\x5a\x1\x94\x70\xb\x6a\x60\xce\x7e\xaa\xea\x56\xf0\x65\x75\x3d\x8d\xcb\x96\x53\xdb\xad\x35\xb\x80\xa0\xac\xec\x76\xe8\x44\x69\xc\xf2\xf5\x83\x17\xd1\x3d\x91\xb\x27\xc\xf0\xb9\xe3\x7\xdb\x80\x94\x40\x2d\xc4\x6b\x4f\x45\x6a\x81\xa0\x57\xd\x6e\xa1\x63\xa5\x5\x89\x6a\xa2\x67\x4d\x56\x81\x0\x33\xcd\x4d\x3\xb1\x37\x87\x6\x5b\x5a\xbe\x57\xcd\xe4\x85\xe5\x2a\x80\xa0\x1a\x82\xa3\x58\x41\x30\x56\x39\xf0\x45\x70\xd2\x10\xf2\xb8\x8e\xd7\xaf\x20\xd9\x51\xb3\x2\xb\x28\x37\x5f\x24\x5d\x1e\xdf\x28\xa0\x35\x9d\x4e\x56\x34\x77\x4b\xe\x25\xc9\x13\xdb\x88\xb2\xf\xec\x8d\xd\xcd\x29\x78\x8d\xa4\xc7\x1e\x9a\x82\x72\x2a\x69\x20\xf5" + } + describe ".initialize" do + it "creates EIP-7702 transaction objects" do + expect(tx).to be + expect(tx).to be_instance_of Tx::Eip7702 + end + + it "doesn't create invalid transaction objects" + end + + describe ".sign" do + it "signs the default transaction" do + tx.sign(cow) + expect(tx.signature_y_parity).to eq 0 + expect(tx.signature_r).to eq "1a82a35841305639f04570d210f2b88ed7af20d951b3020b28375f245d1edf28" + expect(tx.signature_s).to eq "359d4e5634774b0e25c913db88b20fec8d0dcd29788da4c71e9a82722a6920f5" + end + + it "it does not sign a transaction twice" do + expect { type04.hash }.to raise_error StandardError, "Transaction is not signed!" + expect(testnet.address.to_s).to eq "0x4762119a7249823D18aec7EAB73258B2D5061Dd8" + type04.sign(testnet) + expect { type04.sign(testnet) }.to raise_error StandardError, "Transaction is already signed!" + end + + it "checks for a valid sender" do + expect { dog_tx.sign(cow) }.to raise_error StandardError, "Signer does not match sender" + end + end + + describe ".encoded" do + it "encodes the default transaction" do + expect { tx.encoded }.to raise_error StandardError, "Transaction is not signed!" + tx.sign(cow) + + expect(tx.encoded).to eq "\x4\xf9\x1\xa\x1\x80\x80\x1\x82\x52\x8\x80\x80\x80\xc0\xf8\xba\xf8\x5c\x82\x7a\x69\x94\x70\xb\x6a\x60\xce\x7e\xaa\xea\x56\xf0\x65\x75\x3d\x8d\xcb\x96\x53\xdb\xad\x35\x2\x80\xa0\xa4\xf2\xc5\x24\x3c\x3d\x6d\x82\x16\x8e\xf3\x5b\x3d\x3d\xf1\xe5\xc\xef\xee\x1b\xc2\x12\xc7\x69\xbd\x19\x68\x6\x1c\x39\x52\x60\xa0\x7f\x34\x6c\x18\x4\x30\xb\x96\xd6\x87\xa9\xc\xe5\xbc\xea\x8\x83\xc1\x2b\xc4\x5b\x6a\x8a\x29\x4e\x29\xff\x7c\x2\xb4\x2a\x65\xf8\x5a\x1\x94\x70\xb\x6a\x60\xce\x7e\xaa\xea\x56\xf0\x65\x75\x3d\x8d\xcb\x96\x53\xdb\xad\x35\xb\x80\xa0\xac\xec\x76\xe8\x44\x69\xc\xf2\xf5\x83\x17\xd1\x3d\x91\xb\x27\xc\xf0\xb9\xe3\x7\xdb\x80\x94\x40\x2d\xc4\x6b\x4f\x45\x6a\x81\xa0\x57\xd\x6e\xa1\x63\xa5\x5\x89\x6a\xa2\x67\x4d\x56\x81\x0\x33\xcd\x4d\x3\xb1\x37\x87\x6\x5b\x5a\xbe\x57\xcd\xe4\x85\xe5\x2a\x80\xa0\x1a\x82\xa3\x58\x41\x30\x56\x39\xf0\x45\x70\xd2\x10\xf2\xb8\x8e\xd7\xaf\x20\xd9\x51\xb3\x2\xb\x28\x37\x5f\x24\x5d\x1e\xdf\x28\xa0\x35\x9d\x4e\x56\x34\x77\x4b\xe\x25\xc9\x13\xdb\x88\xb2\xf\xec\x8d\xd\xcd\x29\x78\x8d\xa4\xc7\x1e\x9a\x82\x72\x2a\x69\x20\xf5" + end + + it "encodes a known transaction - pending until testnets are live" + end + + describe ".hex" do + it "hexes the default transaction" do + expect { tx.hex }.to raise_error StandardError, "Transaction is not signed!" + tx.sign(cow) + expect(tx.hex).to eq "04f9010a01808001825208808080c0f8baf85c827a6994700b6a60ce7eaaea56f065753d8dcb9653dbad350280a0a4f2c5243c3d6d82168ef35b3d3df1e50cefee1bc212c769bd1968061c395260a07f346c1804300b96d687a90ce5bcea0883c12bc45b6a8a294e29ff7c02b42a65f85a0194700b6a60ce7eaaea56f065753d8dcb9653dbad350b80a0acec76e844690cf2f58317d13d910b270cf0b9e307db8094402dc46b4f456a81a0570d6ea163a505896aa2674d56810033cd4d03b13787065b5abe57cde485e52a80a01a82a35841305639f04570d210f2b88ed7af20d951b3020b28375f245d1edf28a0359d4e5634774b0e25c913db88b20fec8d0dcd29788da4c71e9a82722a6920f5" + end + + it "hexes a known transaction - pending until testnets are live" + end + + describe ".hash" do + it "hashes the default transaction" do + expect { tx.hash }.to raise_error StandardError, "Transaction is not signed!" + tx.sign(cow) + expect(tx.hash).to eq "909ec5f61d2bc645db07001dc27a8aab48caa15b408f229ab5d053c7d4ea5cf7" + end + + it "hashes a known transaction - pending until testnets are live" + end + + describe ".copy" do + it "can duplicate transactions" do + duplicate = Tx.unsigned_copy signed + expect(signed.chain_id).to eq duplicate.chain_id + expect(signed.signer_nonce).to eq duplicate.signer_nonce + expect(signed.max_priority_fee_per_gas).to eq duplicate.max_priority_fee_per_gas + expect(signed.max_fee_per_gas).to eq duplicate.max_fee_per_gas + expect(signed.gas_limit).to eq duplicate.gas_limit + expect(signed.destination).to eq duplicate.destination + expect(signed.amount).to eq duplicate.amount + expect(signed.payload).to eq duplicate.payload + expect(signed.access_list).to eq duplicate.access_list + expect(signed.type).to eq duplicate.type + expect(signed.authorization_list).to eq duplicate.authorization_list + + #unsigned + expect(duplicate.signature_y_parity).not_to be + expect(duplicate.signature_r).to eq 0 + expect(duplicate.signature_s).to eq 0 + + # signed + duplicate.sign cow + expect(signed.signature_y_parity).to eq duplicate.signature_y_parity + expect(signed.signature_r).to eq duplicate.signature_r + expect(signed.signature_s).to eq duplicate.signature_s + end + + it "can duplicate a known transaction" do + eip7702 = Tx.decode signed.hex + duplicate = Tx.unsigned_copy eip7702 + expect(eip7702.chain_id).to eq duplicate.chain_id + expect(eip7702.signer_nonce).to eq duplicate.signer_nonce + expect(eip7702.max_priority_fee_per_gas).to eq duplicate.max_priority_fee_per_gas + expect(eip7702.max_fee_per_gas).to eq duplicate.max_fee_per_gas + expect(eip7702.gas_limit).to eq duplicate.gas_limit + expect(eip7702.destination).to eq duplicate.destination + expect(eip7702.amount).to eq duplicate.amount + expect(eip7702.payload).to eq duplicate.payload + expect(eip7702.access_list).to eq duplicate.access_list + expect(eip7702.type).to eq duplicate.type + expect(eip7702.authorization_list).to eq duplicate.authorization_list + end + + it "can decode an unsigned transaction" do + eip7702 = Tx.decode signed.hex + duplicate = Tx.unsigned_copy eip7702 + decoded = Tx.decode Util.bin_to_hex duplicate.unsigned_encoded + expect(decoded.signature_y_parity).to be nil + expect(decoded.signature_r).to be 0 + expect(decoded.signature_s).to be 0 + end + + it "can duplicate a known transaction - pending until testnets are live" + end + + describe ".decode" do + subject(:transaction_data_missing_the_last_field) { + # take the valid hex from a signed transaction, drop the last field from the data, then re-wrap that as a transaction. + hex = signed.hex + bin = Util.hex_to_bin hex[2..] + tx = Rlp.decode bin + tx = tx[0..-2] + tx_encoded = Rlp.encode tx + # create an EIP-2718 envelope with EIP-7702 type payload + tx_type = Util.serialize_int_to_big_endian Tx::TYPE_7702 + + # return the hex version ready for working with + Util.bin_to_hex "#{tx_type}#{tx_encoded}" + } + + it "gives an error when the transaction is missing a signature field" do + expect { Tx::Eip7702.decode(transaction_data_missing_the_last_field) }.to raise_error Eth::Tx::DecoderError, "Cannot decode EIP-7702 payload!" + end + end + + describe "Authorization" do + describe ".sign" do + it "does not allow signing of an already signed authorization" do + expect { authorization_list.first.sign cow }.to raise_error Signature::SignatureError, "Authorization is already signed!" + end + + it "requires the address to match the signing key" do + expect { unsigned_authorization.sign cow }.to raise_error Signature::SignatureError, "Signer does not match sender" + end + + it "updates the y parity, r and s correctly" do + unsigned_cow_authorization.sign cow + + expect(unsigned_cow_authorization.signature_y_parity).to eq 1 + expect(unsigned_cow_authorization.signature_r).to eq "66cadb13ae65792aaee7c9af01efb056ea3e6d8e14c8ab2dd398d429a645e042" + expect(unsigned_cow_authorization.signature_s).to eq "24d8959748c1cd7e31759b210d8617af6c9709e3b2df22fdfe5f72cabfab04ed" + end + end + end +end