From 865e03ca6a282bf81cc72ca52c4501708ac6ca21 Mon Sep 17 00:00:00 2001 From: Arran Schlosberg <519948+ARR4N@users.noreply.github.com> Date: Mon, 15 Dec 2025 15:46:36 +0000 Subject: [PATCH] feat(abi): pack event and output + unpack input (#249) ## Why this should be merged Allows ICM code to use the `abi` package from `libevm` instead of `subnet-evm`, as part of a broader transition. ## How this works Port and refactor of the following `abi.ABI` methods (with differences described) from `subnet-evm`: 1. `PackEvent()` returns topics and packed data for events. Unlike `subnet-evm`, the returned `[]common.Hash` topic slice MAY be nil instead of the non-nil but empty equivalent. This is in keeping with the upstream `abi.MakeTopics()`. 2. `PackOutput()` returns packed output for methods. 3. `UnpackInputIntoInterface()` unpacks method inputs or event arguments into an arbitrary interface. Unlike the `subnet-evm` implementation, it doesn't perform any checks on the length of the input buffer as these are an Avalanche-specific feature that can be performed by the consumer. ## How this was tested Unit tests from `subnet-evm`, refactored for clarity. --- accounts/abi/abi.libevm.go | 116 +++++++++++++ accounts/abi/abi.libevm_test.go | 294 ++++++++++++++++++++++++++++++++ 2 files changed, 410 insertions(+) create mode 100644 accounts/abi/abi.libevm.go create mode 100644 accounts/abi/abi.libevm_test.go diff --git a/accounts/abi/abi.libevm.go b/accounts/abi/abi.libevm.go new file mode 100644 index 0000000000..7b32590ea4 --- /dev/null +++ b/accounts/abi/abi.libevm.go @@ -0,0 +1,116 @@ +// Copyright 2025 the libevm authors. +// +// The libevm additions to go-ethereum are free software: you can redistribute +// them and/or modify them under the terms of the GNU Lesser General Public License +// as published by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// The libevm additions are distributed in the hope that they will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser +// General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see +// . + +package abi + +import ( + "fmt" + + "github.com/ava-labs/libevm/common" +) + +// PackEvent packs the given `args` to conform with the ABI for the specified +// event. Arguments MUST match the order specified in the event ABI. Indexed +// arguments are returned as topics (the slice of which MAY be nil),as described +// in the [Solidity docs], while the rest are packed into `data`. +// +// Struct, slice, and array arguments are not supported except for `[]byte`. +// +// [Solidity docs]: +// https://docs.soliditylang.org/en/latest/abi-spec.html#encoding-of-indexed-event-parameters +func (abi ABI) PackEvent(name string, args ...any) (topics []common.Hash, data []byte, _ error) { + event, ok := abi.Events[name] + if !ok { + return nil, nil, fmt.Errorf("event %q not found", name) + } + if got, want := len(args), len(event.Inputs); got != want { + return nil, nil, fmt.Errorf("event %q received %d inputs; expecting %d", name, got, want) + } + + var ( + indexed []any + packed []any + packArgs Arguments + ) + for i, arg := range args { + if inp := event.Inputs[i]; inp.Indexed { + indexed = append(indexed, arg) + } else { + packed = append(packed, arg) + packArgs = append(packArgs, inp) + } + } + + topics, err := makeTopics1D(indexed) + if err != nil { + return nil, nil, err + } + if !event.Anonymous { + topics = append([]common.Hash{event.ID}, topics...) + } + + data, err = packArgs.Pack(packed...) + if err != nil { + return nil, nil, err + } + + return topics, data, nil +} + +func makeTopics1D(a []any) ([]common.Hash, error) { + t, err := MakeTopics(a) + if err != nil { + return nil, err + } + return t[0], nil +} + +// PackOutput packs the given `args` to conform with the ABI for the specified +// method's output. +func (abi ABI) PackOutput(method string, args ...any) ([]byte, error) { + m, ok := abi.Methods[method] + if !ok { + return nil, fmt.Errorf("method %q not found", method) + } + return m.Outputs.Pack(args...) +} + +// UnpackInputIntoInterface is equivalent to [ABI.UnpackIntoInterface], with all +// the same caveats, except that it treats `data` as: +// +// 1. Input when handling a method; or +// 2. Unindexed data when handling an event. +func (abi ABI) UnpackInputIntoInterface(v any, methodOrEventName string, data []byte) error { + in, err := abi.methodOrEventInputs(methodOrEventName) + if err != nil { + return err + } + unpacked, err := in.Unpack(data) + if err != nil { + return err + } + return in.Copy(v, unpacked) +} + +func (abi ABI) methodOrEventInputs(name string) (Arguments, error) { + if m, ok := abi.Methods[name]; ok { + return m.Inputs, nil + } + if ev, ok := abi.Events[name]; ok { + return ev.Inputs, nil + } + return nil, fmt.Errorf("no method nor event %q", name) +} diff --git a/accounts/abi/abi.libevm_test.go b/accounts/abi/abi.libevm_test.go new file mode 100644 index 0000000000..eead5eb3cd --- /dev/null +++ b/accounts/abi/abi.libevm_test.go @@ -0,0 +1,294 @@ +// Copyright 2025 the libevm authors. +// +// The libevm additions to go-ethereum are free software: you can redistribute +// them and/or modify them under the terms of the GNU Lesser General Public License +// as published by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// The libevm additions are distributed in the hope that they will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser +// General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see +// . + +package abi + +import ( + "math/big" + "reflect" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ava-labs/libevm/common" + "github.com/ava-labs/libevm/crypto" +) + +func TestEventPackingRoundTrip(t *testing.T) { + tests := []struct { + name string + abiJSON string + eventName string + args []any + wantTopics []common.Hash + wantData []byte + wantUnpacked any // MUST be a pointer + }{ + { + name: "received", + abiJSON: `[{ + "type": "event", + "name": "received", + "anonymous": false, + "inputs": [ + {"indexed": false, "name": "sender", "type": "address"}, + {"indexed": false, "name": "amount", "type": "uint256"}, + {"indexed": false, "name": "memo", "type": "bytes"} + ] + }]`, + eventName: "received", + args: []any{ + common.HexToAddress("0x376c47978271565f56DEB45495afa69E59c16Ab2"), + big.NewInt(1), + []byte{0x88}, + }, + wantTopics: []common.Hash{ + crypto.Keccak256Hash([]byte("received(address,uint256,bytes)")), + }, + wantData: common.Hex2Bytes("000000000000000000000000376c47978271565f56deb45495afa69e59c16ab20000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000018800000000000000000000000000000000000000000000000000000000000000"), + wantUnpacked: &struct { + Sender common.Address + Amount *big.Int + Memo []byte + }{ + common.HexToAddress("0x376c47978271565f56DEB45495afa69E59c16Ab2"), + big.NewInt(1), + []byte{0x88}, + }, + }, + { + name: "anonymous", + abiJSON: `[{ + "type": "event", + "name": "received", + "anonymous": true, + "inputs": [ + {"indexed": false, "name": "sender", "type": "address"}, + {"indexed": false, "name": "amount", "type": "uint256"}, + {"indexed": false, "name": "memo", "type": "bytes"} + ] + }]`, + eventName: "received", + args: []any{ + common.HexToAddress("0x376c47978271565f56DEB45495afa69E59c16Ab2"), + big.NewInt(1), + []byte{0x88}, + }, + wantTopics: nil, + wantData: common.Hex2Bytes("000000000000000000000000376c47978271565f56deb45495afa69e59c16ab20000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000018800000000000000000000000000000000000000000000000000000000000000"), + wantUnpacked: &struct { + Sender common.Address + Amount *big.Int + Memo []byte + }{ + common.HexToAddress("0x376c47978271565f56DEB45495afa69E59c16Ab2"), + big.NewInt(1), + []byte{0x88}, + }, + }, + { + name: "Transfer", + abiJSON: `[{ + "type": "event", + "name": "Transfer", + "anonymous": false, + "inputs": [ + {"indexed": true, "name": "from", "type": "address"}, + {"indexed": true, "name": "to", "type": "address"}, + {"indexed": false, "name": "value", "type": "uint256"} + ] + }]`, + eventName: "Transfer", + args: []any{ + common.HexToAddress("0x8db97C7cEcE249c2b98bDC0226Cc4C2A57BF52FC"), + common.HexToAddress("0x376c47978271565f56DEB45495afa69E59c16Ab2"), + big.NewInt(100), + }, + wantTopics: []common.Hash{ + crypto.Keccak256Hash([]byte("Transfer(address,address,uint256)")), + common.HexToHash("0x0000000000000000000000008db97c7cece249c2b98bdc0226cc4c2a57bf52fc"), + common.HexToHash("0x000000000000000000000000376c47978271565f56deb45495afa69e59c16ab2"), + }, + wantData: common.Hex2Bytes("0000000000000000000000000000000000000000000000000000000000000064"), + wantUnpacked: &struct { + Value *big.Int + }{ + big.NewInt(100), + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + abi, err := JSON(strings.NewReader(test.abiJSON)) + require.NoErrorf(t, err, "JSON(%s)", test.abiJSON) + + t.Run("pack", func(t *testing.T) { + topics, data, err := abi.PackEvent(test.eventName, test.args...) + require.NoErrorf(t, err, "%T.PackEvent(%q, %v...)", abi, test.eventName, test.args) + + assert.Equal(t, test.wantTopics, topics, "topics") + assert.Equal(t, test.wantData, data, "data") + }) + + t.Run("unpack", func(t *testing.T) { + typ := reflect.TypeOf(test.wantUnpacked) + require.Equal(t, reflect.Pointer, typ.Kind(), "unpacking type MUST be a pointer") + + got := reflect.New(typ.Elem()).Interface() + require.NoError(t, abi.UnpackInputIntoInterface(got, test.eventName, test.wantData)) + + if diff := cmp.Diff(test.wantUnpacked, got, compareBigInts()); diff != "" { + t.Errorf("%T.UnpackInputIntoInterface(%T) diff (-want +got):\n%s", abi, got, diff) + } + }) + }) + } +} + +// receiveFuncInput matches the input signature of the "receive" method defined +// by [receiveFuncABI]. +type receiveFuncInput struct { + Sender common.Address + Amount *big.Int + Memo []byte +} + +var receiveFuncABI ABI + +func init() { + var err error + receiveFuncABI, err = JSON(strings.NewReader(` +[{ + "type": "function", + "name": "receive", + "inputs": [ + { + "name": "sender", + "type": "address" + }, + { + "name": "amount", + "type": "uint256" + }, + { + "name": "memo", + "type": "bytes" + } + ], + "outputs": [ + { + "name": "isAllowed", + "type": "bool" + }, + { + "name": "randomNumber", + "type": "uint64" + } + ] +}] +`)) + if err != nil { + panic(err) + } +} + +func TestUnpackInputIntoInterface(t *testing.T) { + tests := []struct { + name string + extraPaddingBytes int + }{ + { + name: "No extra padding to input data", + }, + { + name: "Valid input data with 32 extra bytes", + extraPaddingBytes: 32, + }, + { + name: "Valid input data with 64 extra bytes", + extraPaddingBytes: 64, + }, + { + name: "Valid input data with 33 extra bytes", + extraPaddingBytes: 33, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + abi := receiveFuncABI + const method = "receive" + + input := receiveFuncInput{ + Sender: common.Address{2}, + Amount: big.NewInt(100), + Memo: []byte("hello"), + } + + args := []any{input.Sender, input.Amount, input.Memo} + packed, err := abi.Pack(method, args...) + require.NoErrorf(t, err, "%T.Pack(%q, %v...)", abi, method, args) + + // skip 4 byte selector + data := append(packed[4:], make([]byte, test.extraPaddingBytes)...) + + var got receiveFuncInput + require.NoErrorf(t, abi.UnpackInputIntoInterface(&got, method, data), "%T.UnpackInputIntoInterface()", abi) + + if diff := cmp.Diff(input, got, compareBigInts()); diff != "" { + t.Errorf("%T.Pack() -> %T.UnpackInputIntoInterface(%T, ...) round-trip diff (-want +got):\n%s", abi, abi, got, diff) + } + }) + } +} + +func TestPackOutput(t *testing.T) { + abi := receiveFuncABI + const ( + method = "receive" + boolReturn = true + uint64Return = uint64(42) + ) + want := []any{boolReturn, uint64Return} + + packed, err := abi.PackOutput(method, boolReturn, uint64Return) + require.NoErrorf(t, err, "%T.PackOutput(%q, %v, %v)", abi, method, boolReturn, uint64Return) + + m := abi.Methods["receive"] + got, err := m.Outputs.Unpack(packed) + require.NoErrorf(t, err, "%T.Outputs.Unpack(%T.PackOutput())", m, abi) + + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("%T.PackOutput() -> %T.Outputs.Unpack() round-trip diff (-want +got):\n%s", abi, m, diff) + } +} + +func compareBigInts() cmp.Option { + return cmp.Comparer(func(a, b *big.Int) bool { + switch aN, bN := a == nil, b == nil; { + case aN != bN: + return false + case aN && bN: + return true + default: + return a.Cmp(b) == 0 + } + }) +}