#35726·go-ethereum

abigen v2: generated event unpackers skip the decode for an empty payload, returning nil fields with a nil error

Author: jeanbmarCreated Sep 17, 2026Updated Sep 17, 2026

System information

Geth version: master (abigen v2 codegen) CL client & version: n/a OS & Version: Linux Commit hash : 6c5b3cc35ba3

Expected behaviour

For an event that declares non-indexed arguments, Unpack<Event>Event returns an error when the log carries no payload, since those fields cannot be filled.

Actual behaviour

It returns a nil error and a struct whose non-indexed fields are all nil. Indexed fields are still parsed from the topics, so the result looks valid at the call site.

accounts/abi/abigen/source2.go.tpl:198 skips the decode instead of attempting it:

out := new({{$contract.Type}}{{.Normalized.Name}})
if len(log.Data) > 0 {
    if err := c.abi.UnpackIntoInterface(out, event, log.Data); err != nil {
        return nil, err
    }
}

A malformed payload of any other length is reported correctly; only the empty one is silently accepted. Since topic0 is chosen by the emitter, any contract can emit a log with a well-known event topic and no payload, so consumers that read a non-indexed field dereference nil.

The guard looks unnecessary. abi.Arguments.Unpack already returns "abi: attempting to unmarshal an empty string while arguments are expected" for an empty payload when the event has non-indexed arguments, and returns an empty result when it has none, and Arguments.Copy has the matching early return. Removing the guard errors exactly when the payload is required and missing, and changes nothing for events that declare no non-indexed arguments (checked against events with only indexed arguments and with no arguments at all, including a junk payload on an event that declares none).

The same template already unpacks unconditionally for the error path at line 249.

accounts/abi/bind/v2/base.go has the same shape in BoundContract.UnpackLog and BoundContract.UnpackLogIntoMap. The v1 template is not affected.

Suggested change:

out := new({{$contract.Type}}{{.Normalized.Name}})
if err := c.abi.UnpackIntoInterface(out, event, log.Data); err != nil {
    return nil, err
}

Steps to reproduce the behaviour

Uses the existing test contract in the repo, basic1(uint256 indexed id, uint256 data). Save as accounts/abi/bind/v2/internal/contracts/events/repro_test.go:

package events

import (
	"math/big"
	"testing"

	"github.com/ethereum/go-ethereum/common"
	"github.com/ethereum/go-ethereum/core/types"
)

func TestReproEmptyPayload(t *testing.T) {
	c := NewC()
	log := &types.Log{Topics: []common.Hash{
		c.abi.Events["basic1"].ID,
		common.BigToHash(big.NewInt(7)),
	}}
	out, err := c.UnpackBasic1Event(log)
	t.Logf("empty payload: err=%v Id=%v Data=%v", err, out.Id, out.Data)

	short := &types.Log{Topics: log.Topics, Data: make([]byte, 8)}
	_, err = c.UnpackBasic1Event(short)
	t.Logf("8-byte payload: err=%v", err)

	_ = out.Data.Int64() // what a consumer does with a nil-error result
}

go test ./accounts/abi/bind/v2/internal/contracts/events/ -run TestReproEmptyPayload -v

empty payload: err=<nil> Id=7 Data=<nil>
8-byte payload: err=abi: cannot marshal in to go type: length insufficient 8 require 32

Backtrace

--- FAIL: TestReproEmptyPayload
panic: runtime error: invalid memory address or nil pointer dereference [recovered, repanicked]
[signal SIGSEGV: segmentation violation]

math/big.(*Int).Int64(...)
	math/big/int.go:430
github.com/ethereum/go-ethereum/accounts/abi/bind/v2/internal/contracts/events.TestReproEmptyPayload
	accounts/abi/bind/v2/internal/contracts/events/repro_test.go:25