diff --git a/libevm/triedb/firewood/firewood.go b/libevm/triedb/firewood/firewood.go new file mode 100644 index 00000000000..5448dbde738 --- /dev/null +++ b/libevm/triedb/firewood/firewood.go @@ -0,0 +1,52 @@ +// 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 +// . + +// The firewood package provides a [triedb.DBOverride] backed by [Firewood]. +// +// [Firewood]: https://github.com/ava-labs/firewood +package firewood + +import ( + "errors" + "runtime" + + "github.com/ava-labs/libevm/common" + "github.com/ava-labs/libevm/libevm/stateconf" + "github.com/ava-labs/libevm/trie/trienode" + "github.com/ava-labs/libevm/trie/triestate" + "github.com/ava-labs/libevm/triedb" +) + +var _ triedb.DBOverride = (*database)(nil) + +type database struct { + triedb.DBOverride // TODO(alarso16) remove once this type implements the interface +} + +func (db *database) Update(root, parent common.Hash, block uint64, nodes *trienode.MergedNodeSet, states *triestate.Set, opts ...stateconf.TrieDBUpdateOption) error { + // TODO(alarso16) + var _ *proposals = extras.MergedNodeSet.Get(nodes) + + db.afterUpdate(nodes) // MUST be the last statement before the final return + return errors.New("unimplemented") +} + +// afterUpdate MUST be called at the end of [database.Update] to ensure that the +// Rust handle isn't freed any earlier. This is an overly cautious, defensive +// approach that will make Rustaceans scream "I told you so". +func (db *database) afterUpdate(nodes *trienode.MergedNodeSet) { + runtime.KeepAlive(extras.MergedNodeSet.Get(nodes)) +} diff --git a/libevm/triedb/firewood/proposals.go b/libevm/triedb/firewood/proposals.go new file mode 100644 index 00000000000..5918381391a --- /dev/null +++ b/libevm/triedb/firewood/proposals.go @@ -0,0 +1,95 @@ +// 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 firewood + +import ( + "fmt" + "runtime" + + "github.com/ava-labs/libevm/common" + "github.com/ava-labs/libevm/trie/trienode" +) + +// RegisterExtras registers Firewood proposals with [trienode.RegisterExtras]. +// This MUST be called in and only in tests / package main to avoid polluting +// other packages. A call to RegisterExtras is required for the rest of this +// package to function correctly. +func RegisterExtras() { + extras = trienode.RegisterExtras[proposals, proposals, struct{}]() +} + +var extras trienode.ExtraPayloads[*proposals, *proposals, *struct{}] + +// A proposals carrier is embedded as a payload in the [trienode.NodeSet] object +// returned by trie `Commit()`. A preceding call to [RegisterExtras] ensures +// that the proposals will be propagated to [Database.Update]. +type proposals struct { + // root MUST match the argument returned by the trie's `Commit()` method. + root common.Hash + // handles MAY carry >=1 handle, based off different parents, but all MUST + // result in the same root (i.e. the one specified in the other field). + handles []*handle +} + +func (p *proposals) injectInto(ns *trienode.NodeSet) { + extras.NodeSet.Set(ns, p) +} + +// A handle carries a Firewood FFI proposal handle (i.e. Rust-owned memory). +// After construction, [handle.setFinalizer] SHOULD be called to ensure release +// of resources via [handle.free] once the handle is garbage collected. +type handle struct { + // TODO(alarso16) place the FFI handle here + + // finalized is set by [handle.setFinalizer] to signal when said finalizer + // has run; see https://go.dev/doc/gc-guide#Testing_object_death + finalized chan struct{} +} + +// setFinalizer calls [runtime.SetFinalizer] with `p`. +func (h *handle) setFinalizer() { + h.finalized = make(chan struct{}) + runtime.SetFinalizer(h, (*handle).finalizer) +} + +// finalizer is expected to be passed to [runtime.SetFinalizer], abstracted as a +// method to guarantee that it doesn't accidentally capture the value being +// collected, thus resurrecting it. +func (h *handle) finalizer() { + h.free() + close(h.finalized) +} + +// free is called when the [proposal] is no longer reachable. +func (h *handle) free() { + // TODO(alarso16) free the Rust object(s). +} + +// AfterMergeNodeSet implements [trienode.MergedNodeSetHooks], copying at most +// one proposal handle into the merged set. +func (p *proposals) AfterMergeNodeSet(into *trienode.MergedNodeSet, ns *trienode.NodeSet) error { + if p := extras.MergedNodeSet.Get(into); p.root != (common.Hash{}) { + return fmt.Errorf(">1 %T carrying non-zero %T", ns, p) + } + // The GC finalizer is attached to the [payload], not to the [handle], so + // we have to copy the entire object to ensure that it remains reachable. + extras.MergedNodeSet.Set(into, extras.NodeSet.Get(ns)) + return nil +} + +// AfterAddNode implements [trienode.NodeSetHooks] as a noop. +func (p *proposals) AfterAddNode(*trienode.NodeSet, []byte, *trienode.Node) {} diff --git a/libevm/triedb/firewood/proposals_test.go b/libevm/triedb/firewood/proposals_test.go new file mode 100644 index 00000000000..1f96cec5987 --- /dev/null +++ b/libevm/triedb/firewood/proposals_test.go @@ -0,0 +1,165 @@ +// 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 firewood + +import ( + "context" + "fmt" + "os" + "runtime" + "testing" + "time" + + "github.com/stretchr/testify/require" + "golang.org/x/sync/errgroup" + + "github.com/ava-labs/libevm/common" + "github.com/ava-labs/libevm/core/rawdb" + "github.com/ava-labs/libevm/core/state" + "github.com/ava-labs/libevm/core/types" + "github.com/ava-labs/libevm/ethdb" + "github.com/ava-labs/libevm/libevm/stateconf" + "github.com/ava-labs/libevm/trie" + "github.com/ava-labs/libevm/trie/trienode" + "github.com/ava-labs/libevm/trie/triestate" + "github.com/ava-labs/libevm/triedb" + databasepkg "github.com/ava-labs/libevm/triedb/database" + "github.com/ava-labs/libevm/triedb/hashdb" +) + +func TestMain(m *testing.M) { + RegisterExtras() + os.Exit(m.Run()) +} + +// A cacheWithDummyProposals overrides `OpenTrie()` to returns a +// [trieWithDummyProposals]. +type cacheWithDummyProposals struct { + state.Database +} + +func (db *cacheWithDummyProposals) OpenTrie(root common.Hash) (state.Trie, error) { + t, err := db.Database.OpenTrie(root) + if err != nil { + return nil, err + } + return &trieWithDummyProposals{Trie: t}, nil +} + +// A trieWithDummyProposals overrides `Commit()` to inject a [proposal] into the +// returned [trienode.NodeSet]. +type trieWithDummyProposals struct { + state.Trie +} + +func (t *trieWithDummyProposals) Commit(collectLeaf bool) (common.Hash, *trienode.NodeSet, error) { + root, set, err := t.Trie.Commit(collectLeaf) + if err != nil { + return common.Hash{}, nil, err + } + // This, combined with [proposalPayload.MergeNodeSet], is where the magic + // happens. We use the existing geth plumbing to carry the proposal back to + // [hashDBWithDummyProposals.Update], knowing that the Go GC will trigger + // the FFI call to free the Rust memory. + p := &proposals{ + root: root, + handles: []*handle{{}, {}}, + } + for _, h := range p.handles { + h.setFinalizer() + } + p.injectInto(set) + + return root, set, nil +} + +// A hashDBWithDummyProposals overrides `Update()` to capture the [proposal] +// propagated from [trieWithDummyProposals.Commit]. +type hashDBWithDummyProposals struct { + *hashdb.Database + got *proposals +} + +func (db *hashDBWithDummyProposals) Reader(root common.Hash) (databasepkg.Reader, error) { + return db.Database.Reader(root) +} + +func (db *hashDBWithDummyProposals) Update(root, parent common.Hash, block uint64, nodes *trienode.MergedNodeSet, states *triestate.Set, opts ...stateconf.TrieDBUpdateOption) error { + db.got = extras.MergedNodeSet.Get(nodes) + return db.Database.Update(root, parent, block, nodes, states, opts...) +} + +func TestProposalPropagation(t *testing.T) { + db := rawdb.NewMemoryDatabase() + backend := &hashDBWithDummyProposals{ + Database: hashdb.New(db, nil, trie.MerkleResolver{}), + } + tdb := triedb.NewDatabase(db, &triedb.Config{ + DBOverride: func(db ethdb.Database) triedb.DBOverride { + return backend + }, + }) + + cache := &cacheWithDummyProposals{ + Database: state.NewDatabaseWithNodeDB(db, tdb), + } + sdb, err := state.New(types.EmptyRootHash, cache, nil) + require.NoError(t, err, "state.New([empty root], ...)") + + sdb.SetState(common.Address{}, common.Hash{}, common.Hash{42}) + root, err := sdb.Commit(1, false) + require.NoErrorf(t, err, "%T.Commit()", sdb) + + if got, want := backend.got.root, root; got != want { + t.Errorf("got %v; want %v", got, want) + } + + t.Run("GC_finalizers_invoked", func(t *testing.T) { + var finalized []chan struct{} + for _, h := range backend.got.handles { + finalized = append(finalized, h.finalized) + } + + // Everything that might still hold a reference to the `proposal`, + // stopping it from being garbage collected. + sdb = nil + cache = nil + tdb = nil + backend = nil + + // Note that [runtime.GC] doesn't block on finalizers; see + // https://go.dev/doc/gc-guide#Testing_object_death + runtime.GC() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + g, ctx := errgroup.WithContext(ctx) + + for i, ch := range finalized { + g.Go(func() error { + select { + case <-ch: + return nil + case <-ctx.Done(): + return fmt.Errorf("%T[%d] finalizer didn't run", &handle{}, i) + } + }) + } + + require.NoError(t, g.Wait()) + }) +} diff --git a/trie/trienode/node.go b/trie/trienode/node.go index 8bd0a18ba38..939d042efe7 100644 --- a/trie/trienode/node.go +++ b/trie/trienode/node.go @@ -22,6 +22,7 @@ import ( "strings" "github.com/ava-labs/libevm/common" + "github.com/ava-labs/libevm/libevm/pseudo" ) // Node is a wrapper which contains the encoded blob of the trie node and its @@ -30,6 +31,8 @@ import ( type Node struct { Hash common.Hash // Node hash, empty for deleted node Blob []byte // Encoded node blob, nil for the deleted node + + extra *pseudo.Type } // Size returns the total memory size used by this node. @@ -64,6 +67,8 @@ type NodeSet struct { Nodes map[string]*Node updates int // the count of updated and inserted nodes deletes int // the count of deleted nodes + + extra *pseudo.Type } // NewNodeSet initializes a node set. The owner is zero for the account trie and @@ -97,6 +102,7 @@ func (set *NodeSet) AddNode(path []byte, n *Node) { set.updates += 1 } set.Nodes[string(path)] = n + set.mergePayload(path, n) } // Merge adds a set of nodes into the set. @@ -164,6 +170,8 @@ func (set *NodeSet) Summary() string { // MergedNodeSet represents a merged node set for a group of tries. type MergedNodeSet struct { Sets map[common.Hash]*NodeSet + + extra *pseudo.Type } // NewMergedNodeSet initializes an empty merged set. @@ -180,7 +188,7 @@ func NewWithNodeSet(set *NodeSet) *MergedNodeSet { // Merge merges the provided dirty nodes of a trie into the set. The assumption // is held that no duplicated set belonging to the same trie will be merged twice. -func (set *MergedNodeSet) Merge(other *NodeSet) error { +func (set *MergedNodeSet) merge(other *NodeSet) error { subset, present := set.Sets[other.Owner] if present { return subset.Merge(other.Owner, other.Nodes) diff --git a/trie/trienode/node.libevm.go b/trie/trienode/node.libevm.go new file mode 100644 index 00000000000..b45c0436640 --- /dev/null +++ b/trie/trienode/node.libevm.go @@ -0,0 +1,166 @@ +// 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 trienode + +import ( + "github.com/ava-labs/libevm/libevm/pseudo" + "github.com/ava-labs/libevm/libevm/register" +) + +// MergedNodeSetHooks are called as part of standard [MergedNodeSet] behaviour. +type MergedNodeSetHooks interface { + // AfterMergeNodeSet is called at the end of [MergedNodeSet.Merge], with the + // method receiver and argument propagated. + AfterMergeNodeSet(receiver *MergedNodeSet, _ *NodeSet) error +} + +// NodeSetHooks are called as part of standard [NodeSet] behaviour. +type NodeSetHooks interface { + // AfterAddNode is called at the end of [NodeSet.AddNode], with the method + // receiver and arguments propagated. + AfterAddNode(receiver *NodeSet, path []byte, _ *Node) +} + +// RegisterExtras registers types `MNSPtr`, `NSPtr`, and `N` to be carried as +// extra payloads in [MergedNodeSet], [NodeSet], and [Node] objects +// respectively. It MUST NOT be called more than once. +func RegisterExtras[ + MNS, NS, N any, + MNSPtr interface { + MergedNodeSetHooks + *MNS + }, + NSPtr interface { + NodeSetHooks + *NS + }, + NPtr interface{ *N }, +]() ExtraPayloads[MNSPtr, NSPtr, NPtr] { + payloads := ExtraPayloads[MNSPtr, NSPtr, NPtr]{ + MergedNodeSet: pseudo.NewAccessor[*MergedNodeSet, MNSPtr]( + (*MergedNodeSet).extraPayload, + func(s *MergedNodeSet, t *pseudo.Type) { s.extra = t }, + ), + NodeSet: pseudo.NewAccessor[*NodeSet, NSPtr]( + (*NodeSet).extraPayload, + func(s *NodeSet, t *pseudo.Type) { s.extra = t }, + ), + Node: pseudo.NewAccessor[*Node, NPtr]( + (*Node).extraPayload, + func(n *Node, t *pseudo.Type) { n.extra = t }, + ), + } + + registeredExtras.MustRegister(&extraConstructors{ + newMergedNodeSet: pseudo.NewConstructor[MNS]().NewPointer, // i.e. non-nil MNSPtr + newNodeSet: pseudo.NewConstructor[NS]().NewPointer, // i.e. non-nil NSPtr + newNode: pseudo.NewConstructor[N]().NewPointer, // i.e. non-nil N + hooks: payloads, + }) + + return payloads +} + +// TestOnlyClearRegisteredExtras clears any previous call to [RegisterExtras]. +// It panics if called from a non-testing call stack. +func TestOnlyClearRegisteredExtras() { + registeredExtras.TestOnlyClear() +} + +var registeredExtras register.AtMostOnce[*extraConstructors] + +type extraConstructors struct { + newMergedNodeSet func() *pseudo.Type + newNodeSet func() *pseudo.Type + newNode func() *pseudo.Type + hooks interface { + hooksFromMNS(*MergedNodeSet) MergedNodeSetHooks + hooksFromNS(*NodeSet) NodeSetHooks + } +} + +// Merge merges the provided dirty nodes of a trie into the set. The assumption +// is held that no duplicated set belonging to the same trie will be merged +// twice. +func (set *MergedNodeSet) Merge(other *NodeSet) error { + if err := set.merge(other); err != nil { + return err + } + if r := registeredExtras; r.Registered() { + return r.Get().hooks.hooksFromMNS(set).AfterMergeNodeSet(set, other) + } + return nil +} + +func (set *NodeSet) mergePayload(path []byte, n *Node) { + if r := registeredExtras; r.Registered() { + r.Get().hooks.hooksFromNS(set).AfterAddNode(set, path, n) + } +} + +// ExtraPayloads provides strongly typed access to the extra payloads carried by +// [MergedNodeSet], [NodeSet], and [Node] ojects. The only valid way to +// construct an instance is by a call to [RegisterExtras]. The default `MNSPtr` +// and `NSPtr` default values, returned by [pseudo.Accessor.Get] are guaranteed +// to be non-nil pointers to zero values, equivalent to, e.g. `new(MNS)`. +type ExtraPayloads[ + MNSPtr MergedNodeSetHooks, + NSPtr NodeSetHooks, + NPtr any, +] struct { + MergedNodeSet pseudo.Accessor[*MergedNodeSet, MNSPtr] + NodeSet pseudo.Accessor[*NodeSet, NSPtr] + Node pseudo.Accessor[*Node, NPtr] +} + +func (e ExtraPayloads[MNS, NS, N]) hooksFromMNS(s *MergedNodeSet) MergedNodeSetHooks { + return e.MergedNodeSet.Get(s) +} + +func (e ExtraPayloads[MNS, NS, N]) hooksFromNS(s *NodeSet) NodeSetHooks { + return e.NodeSet.Get(s) +} + +func extraPayloadOrSetDefault(field **pseudo.Type, construct func(*extraConstructors) *pseudo.Type) *pseudo.Type { + r := registeredExtras + if !r.Registered() { + // See params.ChainConfig.extraPayload() for panic rationale. + panic(".extraPayload() called before RegisterExtras()") + } + if *field == nil { + *field = construct(r.Get()) + } + return *field +} + +func (set *MergedNodeSet) extraPayload() *pseudo.Type { + return extraPayloadOrSetDefault(&set.extra, func(c *extraConstructors) *pseudo.Type { + return c.newMergedNodeSet() + }) +} + +func (set *NodeSet) extraPayload() *pseudo.Type { + return extraPayloadOrSetDefault(&set.extra, func(c *extraConstructors) *pseudo.Type { + return c.newNodeSet() + }) +} + +func (n *Node) extraPayload() *pseudo.Type { + return extraPayloadOrSetDefault(&n.extra, func(c *extraConstructors) *pseudo.Type { + return c.newNode() + }) +} diff --git a/trie/trienode/node.libevm_test.go b/trie/trienode/node.libevm_test.go new file mode 100644 index 00000000000..e6bc6e334eb --- /dev/null +++ b/trie/trienode/node.libevm_test.go @@ -0,0 +1,86 @@ +// 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 trienode + +import ( + "maps" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/require" + + "github.com/ava-labs/libevm/common" +) + +type nodePayload struct { + x uint64 +} + +type setPayload struct { + added map[string]uint64 +} + +func (p *setPayload) AfterAddNode(_ *NodeSet, path []byte, n *Node) { + if p.added == nil { + p.added = make(map[string]uint64) + } + p.added[string(path)] = extras.Node.Get(n).x +} + +type mergedSetPayload struct { + merged []map[string]uint64 +} + +func (p *mergedSetPayload) AfterMergeNodeSet(_ *MergedNodeSet, ns *NodeSet) error { + p.merged = append(p.merged, maps.Clone(extras.NodeSet.Get(ns).added)) + return nil +} + +var extras ExtraPayloads[*mergedSetPayload, *setPayload, *nodePayload] + +func TestExtras(t *testing.T) { + extras = RegisterExtras[mergedSetPayload, setPayload, nodePayload]() + t.Cleanup(TestOnlyClearRegisteredExtras) + + n1 := New(common.Hash{0}, nil) + extras.Node.Set(n1, &nodePayload{x: 1}) + n42 := New(common.Hash{1}, nil) + extras.Node.Set(n42, &nodePayload{x: 42}) + + set := NewNodeSet(common.Hash{}) + merge := NewMergedNodeSet() + set.AddNode([]byte("n1"), n1) + require.NoError(t, merge.Merge(set)) + + set.AddNode([]byte("n42"), n42) + require.NoError(t, merge.Merge(set)) + + got := extras.MergedNodeSet.Get(merge).merged + want := []map[string]uint64{ + { + "n1": 1, + }, + { + "n1": 1, + "n42": 42, + }, + } + + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("%T payload diff (-want +got):\n%s", merge, diff) + } +}