Commit dc56c5e9 authored by OptimismBot's avatar OptimismBot Committed by GitHub

Merge pull request #6898 from ethereum-optimism/feat/tx-bundle-builder

feat: tx bundle builder
parents 5628ced6 51bfee47
...@@ -4,46 +4,134 @@ ...@@ -4,46 +4,134 @@
package safe package safe
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"math/big" "math/big"
"strings"
"golang.org/x/exp/maps"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/crypto"
) )
// BatchFile represents a Safe tx-builder transaction. // Batch represents a Safe tx-builder transaction.
type BatchFile struct { type Batch struct {
Version string `json:"version"` Version string `json:"version"`
ChainID *big.Int `json:"chainId"` ChainID *big.Int `json:"chainId"`
CreatedAt uint64 `json:"createdAt"` CreatedAt uint64 `json:"createdAt"`
Meta BatchFileMeta `json:"meta"` Meta BatchMeta `json:"meta"`
Transactions []BatchTransaction `json:"transactions"` Transactions []BatchTransaction `json:"transactions"`
} }
// AddCall will add a call to the batch. After a series of calls are
// added to the batch, it can be serialized to JSON.
func (b *Batch) AddCall(to common.Address, value *big.Int, sig string, args []any, iface abi.ABI) error {
// Attempt to pull out the signature from the top level methods.
// The abi package uses normalization that we do not want to be
// coupled to, so attempt to search for the raw name if the top
// level name is not found to handle overloading more gracefully.
method, ok := iface.Methods[sig]
if !ok {
for _, m := range iface.Methods {
if m.RawName == sig || m.Sig == sig {
method = m
ok = true
}
}
}
if !ok {
keys := maps.Keys(iface.Methods)
methods := strings.Join(keys, ",")
return fmt.Errorf("%s not found in abi, options are %s", sig, methods)
}
if len(args) != len(method.Inputs) {
return fmt.Errorf("requires %d inputs but got %d for %s", len(method.Inputs), len(args), method.RawName)
}
contractMethod := ContractMethod{
Name: method.RawName,
Payable: method.Payable,
}
inputValues := make(map[string]string)
contractInputs := make([]ContractInput, 0)
for i, input := range method.Inputs {
contractInput, err := createContractInput(input, contractInputs)
if err != nil {
return err
}
contractMethod.Inputs = append(contractMethod.Inputs, contractInput...)
str, err := stringifyArg(args[i])
if err != nil {
return err
}
inputValues[input.Name] = str
}
encoded, err := method.Inputs.PackValues(args)
if err != nil {
return err
}
data := make([]byte, len(method.ID)+len(encoded))
copy(data, method.ID)
copy(data[len(method.ID):], encoded)
batchTransaction := BatchTransaction{
To: to,
Value: value,
Method: contractMethod,
Data: data,
InputValues: inputValues,
}
b.Transactions = append(b.Transactions, batchTransaction)
return nil
}
// Check will check the batch for errors
func (b *Batch) Check() error {
for _, tx := range b.Transactions {
if err := tx.Check(); err != nil {
return err
}
}
return nil
}
// bathcFileMarshaling is a helper type used for JSON marshaling. // bathcFileMarshaling is a helper type used for JSON marshaling.
type batchFileMarshaling struct { type batchMarshaling struct {
Version string `json:"version"` Version string `json:"version"`
ChainID string `json:"chainId"` ChainID string `json:"chainId"`
CreatedAt uint64 `json:"createdAt"` CreatedAt uint64 `json:"createdAt"`
Meta BatchFileMeta `json:"meta"` Meta BatchMeta `json:"meta"`
Transactions []BatchTransaction `json:"transactions"` Transactions []BatchTransaction `json:"transactions"`
} }
// MarshalJSON will marshal a BatchFile to JSON. // MarshalJSON will marshal a Batch to JSON.
func (b *BatchFile) MarshalJSON() ([]byte, error) { func (b *Batch) MarshalJSON() ([]byte, error) {
return json.Marshal(batchFileMarshaling{ batch := batchMarshaling{
Version: b.Version, Version: b.Version,
ChainID: b.ChainID.String(),
CreatedAt: b.CreatedAt, CreatedAt: b.CreatedAt,
Meta: b.Meta, Meta: b.Meta,
Transactions: b.Transactions, Transactions: b.Transactions,
}) }
if b.ChainID != nil {
batch.ChainID = b.ChainID.String()
}
return json.Marshal(batch)
} }
// UnmarshalJSON will unmarshal a BatchFile from JSON. // UnmarshalJSON will unmarshal a Batch from JSON.
func (b *BatchFile) UnmarshalJSON(data []byte) error { func (b *Batch) UnmarshalJSON(data []byte) error {
var bf batchFileMarshaling var bf batchMarshaling
if err := json.Unmarshal(data, &bf); err != nil { if err := json.Unmarshal(data, &bf); err != nil {
return err return err
} }
...@@ -59,9 +147,9 @@ func (b *BatchFile) UnmarshalJSON(data []byte) error { ...@@ -59,9 +147,9 @@ func (b *BatchFile) UnmarshalJSON(data []byte) error {
return nil return nil
} }
// BatchFileMeta contains metadata about a BatchFile. Not all // BatchMeta contains metadata about a Batch. Not all
// of the fields are required. // of the fields are required.
type BatchFileMeta struct { type BatchMeta struct {
TxBuilderVersion string `json:"txBuilderVersion,omitempty"` TxBuilderVersion string `json:"txBuilderVersion,omitempty"`
Checksum string `json:"checksum,omitempty"` Checksum string `json:"checksum,omitempty"`
CreatedFromSafeAddress string `json:"createdFromSafeAddress"` CreatedFromSafeAddress string `json:"createdFromSafeAddress"`
...@@ -79,6 +167,81 @@ type BatchTransaction struct { ...@@ -79,6 +167,81 @@ type BatchTransaction struct {
InputValues map[string]string `json:"contractInputsValues"` InputValues map[string]string `json:"contractInputsValues"`
} }
// Check will check the batch transaction for errors.
// An error is defined by:
// - incorrectly encoded calldata
// - mismatch in number of arguments
// It does not currently work on structs, will return no error if a "tuple"
// is used as an argument. Need to find a generic way to work with structs.
func (bt *BatchTransaction) Check() error {
if len(bt.Method.Inputs) != len(bt.InputValues) {
return fmt.Errorf("expected %d inputs but got %d", len(bt.Method.Inputs), len(bt.InputValues))
}
if len(bt.Data) > 0 && bt.Method.Name != "fallback" {
if len(bt.Data) < 4 {
return fmt.Errorf("must have at least 4 bytes of calldata, got %d", len(bt.Data))
}
sig := bt.Signature()
selector := crypto.Keccak256([]byte(sig))[0:4]
if !bytes.Equal(bt.Data[0:4], selector) {
return fmt.Errorf("data does not match signature")
}
// Check the calldata
values := make([]any, len(bt.Method.Inputs))
for i, input := range bt.Method.Inputs {
value, ok := bt.InputValues[input.Name]
if !ok {
return fmt.Errorf("missing input %s", input.Name)
}
// Need to figure out better way to handle tuples in a generic way
if input.Type == "tuple" {
return nil
}
arg, err := unstringifyArg(value, input.Type)
if err != nil {
return err
}
values[i] = arg
}
calldata, err := bt.Arguments().PackValues(values)
if err != nil {
return err
}
if !bytes.Equal(bt.Data[4:], calldata) {
return fmt.Errorf("calldata does not match inputs, expected %s, got %s", hexutil.Encode(bt.Data[4:]), hexutil.Encode(calldata))
}
}
return nil
}
// Signature returns the function signature of the batch transaction.
func (bt *BatchTransaction) Signature() string {
types := make([]string, len(bt.Method.Inputs))
for i, input := range bt.Method.Inputs {
types[i] = buildFunctionSignature(input)
}
return fmt.Sprintf("%s(%s)", bt.Method.Name, strings.Join(types, ","))
}
func (bt *BatchTransaction) Arguments() abi.Arguments {
arguments := make(abi.Arguments, len(bt.Method.Inputs))
for i, input := range bt.Method.Inputs {
serialized, err := json.Marshal(input)
if err != nil {
panic(err)
}
var arg abi.Argument
if err := json.Unmarshal(serialized, &arg); err != nil {
panic(err)
}
arguments[i] = arg
}
return arguments
}
// UnmarshalJSON will unmarshal a BatchTransaction from JSON. // UnmarshalJSON will unmarshal a BatchTransaction from JSON.
func (b *BatchTransaction) UnmarshalJSON(data []byte) error { func (b *BatchTransaction) UnmarshalJSON(data []byte) error {
var bt batchTransactionMarshaling var bt batchTransactionMarshaling
...@@ -87,6 +250,9 @@ func (b *BatchTransaction) UnmarshalJSON(data []byte) error { ...@@ -87,6 +250,9 @@ func (b *BatchTransaction) UnmarshalJSON(data []byte) error {
} }
b.To = common.HexToAddress(bt.To) b.To = common.HexToAddress(bt.To)
b.Value = new(big.Int).SetUint64(bt.Value) b.Value = new(big.Int).SetUint64(bt.Value)
if bt.Data != nil {
b.Data = common.CopyBytes(*bt.Data)
}
b.Method = bt.Method b.Method = bt.Method
b.InputValues = bt.InputValues b.InputValues = bt.InputValues
return nil return nil
...@@ -101,8 +267,8 @@ func (b *BatchTransaction) MarshalJSON() ([]byte, error) { ...@@ -101,8 +267,8 @@ func (b *BatchTransaction) MarshalJSON() ([]byte, error) {
InputValues: b.InputValues, InputValues: b.InputValues,
} }
if len(b.Data) != 0 { if len(b.Data) != 0 {
hex := hexutil.Encode(b.Data) data := hexutil.Bytes(b.Data)
batch.Data = &hex batch.Data = &data
} }
return json.Marshal(batch) return json.Marshal(batch)
} }
...@@ -111,7 +277,7 @@ func (b *BatchTransaction) MarshalJSON() ([]byte, error) { ...@@ -111,7 +277,7 @@ func (b *BatchTransaction) MarshalJSON() ([]byte, error) {
type batchTransactionMarshaling struct { type batchTransactionMarshaling struct {
To string `json:"to"` To string `json:"to"`
Value uint64 `json:"value,string"` Value uint64 `json:"value,string"`
Data *string `json:"data"` Data *hexutil.Bytes `json:"data"`
Method ContractMethod `json:"contractMethod"` Method ContractMethod `json:"contractMethod"`
InputValues map[string]string `json:"contractInputsValues"` InputValues map[string]string `json:"contractInputsValues"`
} }
......
package safe
import (
"bytes"
"encoding/json"
"os"
"testing"
"github.com/stretchr/testify/require"
)
func TestBatchFileJSONPrepareBedrock(t *testing.T) {
testBatchFileJSON(t, "testdata/batch-prepare-bedrock.json")
}
func TestBatchFileJSONL2OO(t *testing.T) {
testBatchFileJSON(t, "testdata/l2-output-oracle.json")
}
func testBatchFileJSON(t *testing.T, path string) {
b, err := os.ReadFile(path)
require.NoError(t, err)
dec := json.NewDecoder(bytes.NewReader(b))
decoded := new(BatchFile)
require.NoError(t, dec.Decode(decoded))
data, err := json.Marshal(decoded)
require.NoError(t, err)
require.JSONEq(t, string(b), string(data))
}
package safe
import (
"fmt"
"math/big"
"reflect"
"strconv"
"strings"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/common/hexutil"
)
// stringifyArg converts a Go type to a string that is representable by ABI.
// To do so, this function must be recursive to handle nested tuples.
func stringifyArg(argument any) (string, error) {
switch arg := argument.(type) {
case common.Address:
return arg.String(), nil
case *common.Address:
return arg.String(), nil
case *big.Int:
return arg.String(), nil
case big.Int:
return arg.String(), nil
case bool:
if arg {
return "true", nil
}
return "false", nil
case int64:
return strconv.FormatInt(arg, 10), nil
case int32:
return strconv.FormatInt(int64(arg), 10), nil
case int16:
return strconv.FormatInt(int64(arg), 10), nil
case int8:
return strconv.FormatInt(int64(arg), 10), nil
case int:
return strconv.FormatInt(int64(arg), 10), nil
case uint64:
return strconv.FormatUint(uint64(arg), 10), nil
case uint32:
return strconv.FormatUint(uint64(arg), 10), nil
case uint16:
return strconv.FormatUint(uint64(arg), 10), nil
case uint8:
return strconv.FormatUint(uint64(arg), 10), nil
case uint:
return strconv.FormatUint(uint64(arg), 10), nil
case []byte:
return hexutil.Encode(arg), nil
case []any:
ret := make([]string, len(arg))
for i, v := range arg {
str, err := stringifyArg(v)
if err != nil {
return "", err
}
ret[i] = str
}
return "[" + strings.Join(ret, ",") + "]", nil
default:
typ := reflect.TypeOf(argument)
if typ.Kind() == reflect.Ptr {
typ = typ.Elem()
}
if typ.Kind() == reflect.Struct {
v := reflect.ValueOf(argument)
numField := v.NumField()
ret := make([]string, numField)
for i := 0; i < numField; i++ {
val := v.Field(i).Interface()
str, err := stringifyArg(val)
if err != nil {
return "", err
}
ret[i] = str
}
return "[" + strings.Join(ret, ",") + "]", nil
}
return "", fmt.Errorf("unknown type as argument: %T", arg)
}
}
// unstringifyArg converts a string to a Go type.
func unstringifyArg(arg string, typ string) (any, error) {
switch typ {
case "address":
return common.HexToAddress(arg), nil
case "bool":
return strconv.ParseBool(arg)
case "uint8":
val, err := strconv.ParseUint(arg, 10, 8)
return uint8(val), err
case "uint16":
val, err := strconv.ParseUint(arg, 10, 16)
return uint16(val), err
case "uint32":
val, err := strconv.ParseUint(arg, 10, 32)
return uint32(val), err
case "uint64":
val, err := strconv.ParseUint(arg, 10, 64)
return val, err
case "int8":
val, err := strconv.ParseInt(arg, 10, 8)
return val, err
case "int16":
val, err := strconv.ParseInt(arg, 10, 16)
return val, err
case "int32":
val, err := strconv.ParseInt(arg, 10, 32)
return val, err
case "int64":
val, err := strconv.ParseInt(arg, 10, 64)
return val, err
case "uint256", "int256":
val, ok := new(big.Int).SetString(arg, 10)
if !ok {
return nil, fmt.Errorf("failed to parse %s as big.Int", arg)
}
return val, nil
case "string":
return arg, nil
case "bytes":
return hexutil.Decode(arg)
default:
return nil, fmt.Errorf("unknown type: %s", typ)
}
}
// createContractInput converts an abi.Argument to one or more ContractInputs.
func createContractInput(input abi.Argument, inputs []ContractInput) ([]ContractInput, error) {
inputType, err := stringifyType(input.Type)
if err != nil {
return nil, err
}
// TODO: could probably do better than string comparison?
internalType := input.Type.String()
if inputType == "tuple" {
internalType = input.Type.TupleRawName
}
components := make([]ContractInput, 0)
for i, elem := range input.Type.TupleElems {
e := *elem
arg := abi.Argument{
Name: input.Type.TupleRawNames[i],
Type: e,
}
component, err := createContractInput(arg, inputs)
if err != nil {
return nil, err
}
components = append(components, component...)
}
contractInput := ContractInput{
InternalType: internalType,
Name: input.Name,
Type: inputType,
Components: components,
}
inputs = append(inputs, contractInput)
return inputs, nil
}
// stringifyType turns an abi.Type into a string
func stringifyType(t abi.Type) (string, error) {
switch t.T {
case abi.TupleTy:
return "tuple", nil
case abi.BoolTy:
return t.String(), nil
case abi.AddressTy:
return t.String(), nil
case abi.UintTy:
return t.String(), nil
case abi.IntTy:
return t.String(), nil
case abi.StringTy:
return t.String(), nil
case abi.BytesTy:
return t.String(), nil
default:
return "", fmt.Errorf("unknown type: %d", t.T)
}
}
// buildFunctionSignature builds a function signature from a ContractInput.
// It is recursive to handle tuples.
func buildFunctionSignature(input ContractInput) string {
if input.Type == "tuple" {
types := make([]string, len(input.Components))
for i, component := range input.Components {
types[i] = buildFunctionSignature(component)
}
return fmt.Sprintf("(%s)", strings.Join(types, ","))
}
return input.InternalType
}
package safe
import (
"bytes"
"encoding/json"
"errors"
"math/big"
"os"
"testing"
"github.com/ethereum-optimism/optimism/op-bindings/bindings"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/stretchr/testify/require"
)
func TestBatchJSONPrepareBedrock(t *testing.T) {
testBatchJSON(t, "testdata/batch-prepare-bedrock.json")
}
func TestBatchJSONL2OO(t *testing.T) {
testBatchJSON(t, "testdata/l2-output-oracle.json")
}
func testBatchJSON(t *testing.T, path string) {
b, err := os.ReadFile(path)
require.NoError(t, err)
dec := json.NewDecoder(bytes.NewReader(b))
decoded := new(Batch)
require.NoError(t, dec.Decode(decoded))
data, err := json.Marshal(decoded)
require.NoError(t, err)
require.JSONEq(t, string(b), string(data))
}
// TestBatchAddCallFinalizeWithdrawalTransaction ensures that structs can be serialized correctly.
func TestBatchAddCallFinalizeWithdrawalTransaction(t *testing.T) {
file, err := os.ReadFile("testdata/portal-abi.json")
require.NoError(t, err)
portalABI, err := abi.JSON(bytes.NewReader(file))
require.NoError(t, err)
sig := "finalizeWithdrawalTransaction"
argument := []any{
bindings.TypesWithdrawalTransaction{
Nonce: big.NewInt(0),
Sender: common.Address{19: 0x01},
Target: common.Address{19: 0x02},
Value: big.NewInt(1),
GasLimit: big.NewInt(2),
Data: []byte{},
},
}
batch := new(Batch)
to := common.Address{19: 0x01}
value := big.NewInt(222)
require.NoError(t, batch.AddCall(to, value, sig, argument, portalABI))
require.NoError(t, batch.Check())
require.Equal(t, batch.Transactions[0].Signature(), "finalizeWithdrawalTransaction((uint256,address,address,uint256,uint256,bytes))")
expected, err := os.ReadFile("testdata/finalize-withdrawal-tx.json")
require.NoError(t, err)
serialized, err := json.Marshal(batch)
require.NoError(t, err)
require.JSONEq(t, string(expected), string(serialized))
}
// TestBatchAddCallDespostTransaction ensures that simple calls can be serialized correctly.
func TestBatchAddCallDespositTransaction(t *testing.T) {
file, err := os.ReadFile("testdata/portal-abi.json")
require.NoError(t, err)
portalABI, err := abi.JSON(bytes.NewReader(file))
require.NoError(t, err)
batch := new(Batch)
to := common.Address{19: 0x01}
value := big.NewInt(222)
sig := "depositTransaction"
argument := []any{
common.Address{01},
big.NewInt(2),
uint64(100),
false,
[]byte{},
}
require.NoError(t, batch.AddCall(to, value, sig, argument, portalABI))
require.NoError(t, batch.Check())
require.Equal(t, batch.Transactions[0].Signature(), "depositTransaction(address,uint256,uint64,bool,bytes)")
expected, err := os.ReadFile("testdata/deposit-tx.json")
require.NoError(t, err)
serialized, err := json.Marshal(batch)
require.NoError(t, err)
require.JSONEq(t, string(expected), string(serialized))
}
// TestBatchCheck checks for the various failure cases of Batch.Check
// as well as a simple check for a valid batch.
func TestBatchCheck(t *testing.T) {
cases := []struct {
name string
bt BatchTransaction
err error
}{
{
name: "bad-input-count",
bt: BatchTransaction{
Method: ContractMethod{},
InputValues: map[string]string{
"foo": "bar",
},
},
err: errors.New("expected 0 inputs but got 1"),
},
{
name: "bad-calldata-too-small",
bt: BatchTransaction{
Data: []byte{0x01},
},
err: errors.New("must have at least 4 bytes of calldata, got 1"),
},
{
name: "bad-calldata-mismatch",
bt: BatchTransaction{
Data: []byte{0x01, 0x02, 0x03, 0x04},
Method: ContractMethod{
Name: "foo",
},
},
err: errors.New("data does not match signature"),
},
{
name: "good-calldata",
bt: BatchTransaction{
Data: []byte{0xc2, 0x98, 0x55, 0x78},
Method: ContractMethod{
Name: "foo",
},
},
err: nil,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.err, tc.bt.Check())
})
}
}
{
"version": "",
"chainId": "",
"createdAt": 0,
"meta": {
"createdFromSafeAddress": "",
"createdFromOwnerAddress": "",
"name": "",
"description": ""
},
"transactions": [
{
"to": "0x0000000000000000000000000000000000000001",
"value": "222",
"data": "0xe9e05c42000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000064000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000",
"contractMethod": {
"inputs": [
{
"internalType": "address",
"name": "_to",
"type": "address"
},
{
"internalType": "uint256",
"name": "_value",
"type": "uint256"
},
{
"internalType": "uint64",
"name": "_gasLimit",
"type": "uint64"
},
{
"internalType": "bool",
"name": "_isCreation",
"type": "bool"
},
{
"internalType": "bytes",
"name": "_data",
"type": "bytes"
}
],
"name": "depositTransaction",
"payable": false
},
"contractInputsValues": {
"_data": "0x",
"_gasLimit": "100",
"_isCreation": "false",
"_to": "0x0100000000000000000000000000000000000000",
"_value": "2"
}
}
]
}
{
"version": "",
"chainId": "",
"createdAt": 0,
"meta": {
"createdFromSafeAddress": "",
"createdFromOwnerAddress": "",
"name": "",
"description": ""
},
"transactions": [
{
"to": "0x0000000000000000000000000000000000000001",
"value": "222",
"data": "0x8c3152e900000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000000",
"contractMethod": {
"inputs": [
{
"internalType": "TypesWithdrawalTransaction",
"name": "_tx",
"type": "tuple",
"components": [
{
"internalType": "uint256",
"name": "nonce",
"type": "uint256"
},
{
"internalType": "address",
"name": "sender",
"type": "address"
},
{
"internalType": "address",
"name": "target",
"type": "address"
},
{
"internalType": "uint256",
"name": "value",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "gasLimit",
"type": "uint256"
},
{
"internalType": "bytes",
"name": "data",
"type": "bytes"
}
]
}
],
"name": "finalizeWithdrawalTransaction",
"payable": false
},
"contractInputsValues": {
"_tx": "[0,0x0000000000000000000000000000000000000001,0x0000000000000000000000000000000000000002,1,2,0x]"
}
}
]
}
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment