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
+ }
+ })
+}