diff --git a/ledger/complete/ledger.go b/ledger/complete/ledger.go index 6ceb65c7dd5..82966abb3b6 100644 --- a/ledger/complete/ledger.go +++ b/ledger/complete/ledger.go @@ -45,6 +45,8 @@ type Ledger struct { pathFinderVersion uint8 } +var _ ledger.Ledger = (*Ledger)(nil) + // NewLedger creates a new in-memory trie-backed ledger storage with persistence. func NewLedger( wal realWAL.LedgerWAL, diff --git a/ledger/complete/payloadless_ledger.go b/ledger/complete/payloadless_ledger.go new file mode 100644 index 00000000000..d840afe2237 --- /dev/null +++ b/ledger/complete/payloadless_ledger.go @@ -0,0 +1,315 @@ +package complete + +import ( + "fmt" + "time" + + "github.com/rs/zerolog" + + "github.com/onflow/flow-go/ledger" + "github.com/onflow/flow-go/ledger/common/hash" + "github.com/onflow/flow-go/ledger/common/pathfinder" + "github.com/onflow/flow-go/ledger/complete/payloadless" + "github.com/onflow/flow-go/model/flow" + "github.com/onflow/flow-go/module" +) + +// PayloadlessLedger is a fork-aware, in-memory trie-based key/leaf-hash storage. +// +// Unlike [Ledger], the underlying trie does not retain payload values: each leaf +// only retains its hash (HashLeaf(path, value)). Reads therefore return leaf +// hashes rather than the original values. Use this variant when the caller only +// needs commitment-level verification (e.g. payloadless execution) and does not +// need the values themselves. +// +// PayloadlessLedger is fork-aware: any update can be applied at any previous +// state which forms a tree of tries (forest). The forest is kept entirely in +// memory and is bounded by `forestCapacity`. When more tries are added than the +// capacity, the Least Recently Added trie is removed (FIFO). +// +// PayloadlessLedger is currently in-memory only; it does not persist updates +// to a write-ahead log. +type PayloadlessLedger struct { + forest *payloadless.Forest + metrics module.LedgerMetrics + logger zerolog.Logger + pathFinderVersion uint8 +} + +// NewPayloadlessLedger creates a new in-memory payloadless trie-backed ledger. +// +// `capacity` bounds the number of tries kept in the forest; the least-recently +// added trie is evicted once capacity is exceeded. +func NewPayloadlessLedger( + capacity int, + metrics module.LedgerMetrics, + log zerolog.Logger, + pathFinderVer uint8, +) (*PayloadlessLedger, error) { + + logger := log.With().Str("ledger_mod", "complete-payloadless").Logger() + + forest, err := payloadless.NewForest(capacity, metrics, nil) + if err != nil { + return nil, fmt.Errorf("cannot create payloadless forest: %w", err) + } + + return &PayloadlessLedger{ + forest: forest, + metrics: metrics, + logger: logger, + pathFinderVersion: pathFinderVer, + }, nil +} + +// Ready implements module.ReadyDoneAware. The payloadless ledger has no +// asynchronous initialization, so the returned channel is already closed. +func (l *PayloadlessLedger) Ready() <-chan struct{} { + ready := make(chan struct{}) + close(ready) + return ready +} + +// Done implements module.ReadyDoneAware. The payloadless ledger has no +// background workers, so the returned channel is already closed. +func (l *PayloadlessLedger) Done() <-chan struct{} { + done := make(chan struct{}) + close(done) + return done +} + +// InitialState returns the state of an empty ledger. +func (l *PayloadlessLedger) InitialState() ledger.State { + return ledger.State(l.forest.GetEmptyRootHash()) +} + +// HasState returns true if the given state exists inside the ledger. +func (l *PayloadlessLedger) HasState(state ledger.State) bool { + return l.forest.HasTrie(ledger.RootHash(state)) +} + +// HasPaths reports, for each key in `query`, whether the key has an allocated +// register at the given state. The returned slice is in the same order as +// `query.Keys()`. +// +// HasPaths replaces the full ledger's ValueSizes for payloadless mode, since +// the payloadless trie does not retain payload byte sizes. +func (l *PayloadlessLedger) HasPaths(query *ledger.Query) ([]bool, error) { + paths, err := pathfinder.KeysToPaths(query.Keys(), l.pathFinderVersion) + if err != nil { + return nil, err + } + trieRead := &ledger.TrieRead{RootHash: ledger.RootHash(query.State()), Paths: paths} + return l.forest.HasPaths(trieRead) +} + +// GetSingleLeafHash returns the leaf hash (HashLeaf(path, value)) for the +// given key at the given state. Returns nil if the path has no allocated +// register. +// +// GetSingleLeafHash replaces the full ledger's GetSingleValue for payloadless +// mode, since payload values are not retained. +func (l *PayloadlessLedger) GetSingleLeafHash(query *ledger.QuerySingleValue) (*hash.Hash, error) { + start := time.Now() + path, err := pathfinder.KeyToPath(query.Key(), l.pathFinderVersion) + if err != nil { + return nil, err + } + trieRead := &ledger.TrieReadSingleValue{RootHash: ledger.RootHash(query.State()), Path: path} + leafHash, err := l.forest.ReadSingleLeafHash(trieRead) + if err != nil { + return nil, err + } + + l.metrics.ReadValuesNumber(1) + readDuration := time.Since(start) + l.metrics.ReadDuration(readDuration) + l.metrics.ReadDurationPerItem(readDuration) + + return leafHash, nil +} + +// GetLeafHashes returns leaf hashes for the given keys at the given state, +// in the same order as `query.Keys()`. A nil entry indicates the path has no +// allocated register at the given state. +// +// GetLeafHashes replaces the full ledger's Get for payloadless mode, since +// payload values are not retained. +func (l *PayloadlessLedger) GetLeafHashes(query *ledger.Query) ([]*hash.Hash, error) { + start := time.Now() + paths, err := pathfinder.KeysToPaths(query.Keys(), l.pathFinderVersion) + if err != nil { + return nil, err + } + trieRead := &ledger.TrieRead{RootHash: ledger.RootHash(query.State()), Paths: paths} + leafHashes, err := l.forest.ReadLeafHashes(trieRead) + if err != nil { + return nil, err + } + + l.metrics.ReadValuesNumber(uint64(len(paths))) + readDuration := time.Since(start) + l.metrics.ReadDuration(readDuration) + + if len(paths) > 0 { + durationPerValue := time.Duration(readDuration.Nanoseconds()/int64(len(paths))) * time.Nanosecond + l.metrics.ReadDurationPerItem(durationPerValue) + } + + return leafHashes, nil +} + +// Set applies the given update to the ledger and returns the new state and +// the trie update that was applied. The update payload's `value` bytes are +// hashed into the trie; the payload's key is not retained. +func (l *PayloadlessLedger) Set(update *ledger.Update) (newState ledger.State, trieUpdate *ledger.TrieUpdate, err error) { + if update.Size() == 0 { + return update.State(), + &ledger.TrieUpdate{ + RootHash: ledger.RootHash(update.State()), + Paths: []ledger.Path{}, + Payloads: []*ledger.Payload{}, + }, + nil + } + + start := time.Now() + + trieUpdate, err = pathfinder.UpdateToTrieUpdate(update, l.pathFinderVersion) + if err != nil { + return ledger.State(hash.DummyHash), nil, err + } + + l.metrics.UpdateCount() + + newTrie, err := l.forest.NewTrie(trieUpdate) + if err != nil { + return ledger.State(hash.DummyHash), nil, fmt.Errorf("cannot update state: %w", err) + } + + err = l.forest.AddTrie(newTrie) + if err != nil { + return ledger.State(hash.DummyHash), nil, fmt.Errorf("failed to add new trie to forest: %w", err) + } + + newState = ledger.State(newTrie.RootHash()) + + elapsed := time.Since(start) + l.metrics.UpdateDuration(elapsed) + + if len(trieUpdate.Paths) > 0 { + durationPerValue := time.Duration(elapsed.Nanoseconds() / int64(len(trieUpdate.Paths))) + l.metrics.UpdateDurationPerItem(durationPerValue) + } + + state := update.State() + l.logger.Info().Hex("from", state[:]). + Hex("to", newState[:]). + Int("update_size", update.Size()). + Msg("payloadless ledger updated") + return newState, trieUpdate, nil +} + +// Prove returns a payloadless batch proof for the given keys at the given +// state. The returned proofs carry leaf hashes rather than full payload values. +// +// Proofs are generally _not_ provided in the register order of the query. +// In the current implementation, proofs follow the order specified by the +// underlying payloadless forest implementation. +func (l *PayloadlessLedger) Prove(query *ledger.Query) (*ledger.PayloadlessTrieBatchProof, error) { + paths, err := pathfinder.KeysToPaths(query.Keys(), l.pathFinderVersion) + if err != nil { + return nil, err + } + + trieRead := &ledger.TrieRead{RootHash: ledger.RootHash(query.State()), Paths: paths} + batchProof, err := l.forest.Proofs(trieRead) + if err != nil { + return nil, fmt.Errorf("could not get proofs: %w", err) + } + + return batchProof, nil +} + +// MemSize returns the amount of memory used by the ledger. +// TODO implement an approximate MemSize method. +func (l *PayloadlessLedger) MemSize() (int64, error) { + return 0, nil +} + +// ForestSize returns the number of tries stored in the forest. +func (l *PayloadlessLedger) ForestSize() int { + return l.forest.Size() +} + +// Tries returns the tries stored in the forest. +func (l *PayloadlessLedger) Tries() ([]*payloadless.MTrie, error) { + return l.forest.GetTries() +} + +// Trie returns the trie stored in the forest with the given root hash. +// +// Expected error returns during normal operation: +// - an error if no trie with the given root hash is stored in the forest +func (l *PayloadlessLedger) Trie(rootHash ledger.RootHash) (*payloadless.MTrie, error) { + return l.forest.GetTrie(rootHash) +} + +// MostRecentTouchedState returns the state most recently touched. +// +// Expected error returns during normal operation: +// - an error if no trie is stored in the forest +func (l *PayloadlessLedger) MostRecentTouchedState() (ledger.State, error) { + root, err := l.forest.MostRecentTouchedRootHash() + return ledger.State(root), err +} + +// FindTrieByStateCommit iterates over the ledger tries and compares the root +// hash to the state commitment. Returns a nil trie if no match is found. +func (l *PayloadlessLedger) FindTrieByStateCommit(commitment flow.StateCommitment) (*payloadless.MTrie, error) { + tries, err := l.Tries() + if err != nil { + return nil, err + } + for _, t := range tries { + if t.RootHash().Equals(ledger.RootHash(commitment)) { + return t, nil + } + } + return nil, nil +} + +// StateCount returns the number of states (tries) stored in the forest. +func (l *PayloadlessLedger) StateCount() int { + return l.ForestSize() +} + +// StateByIndex returns the state at the given index. `-1` returns the last index. +// +// Expected error returns during normal operation: +// - an error if no states are available in the forest +// - an error if the given index is out of range +func (l *PayloadlessLedger) StateByIndex(index int) (ledger.State, error) { + tries, err := l.Tries() + if err != nil { + return ledger.DummyState, fmt.Errorf("failed to get tries: %w", err) + } + + count := len(tries) + if count == 0 { + return ledger.DummyState, fmt.Errorf("no states available") + } + + if index < 0 { + index = count + index + if index < 0 { + return ledger.DummyState, fmt.Errorf("index %d is out of range (count: %d)", index-count, count) + } + } + + if index >= count { + return ledger.DummyState, fmt.Errorf("index %d is out of range (count: %d)", index, count) + } + + return ledger.State(tries[index].RootHash()), nil +} diff --git a/ledger/complete/payloadless_ledger_test.go b/ledger/complete/payloadless_ledger_test.go new file mode 100644 index 00000000000..ce18d8588e8 --- /dev/null +++ b/ledger/complete/payloadless_ledger_test.go @@ -0,0 +1,368 @@ +package complete_test + +import ( + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/onflow/flow-go/ledger" + "github.com/onflow/flow-go/ledger/common/hash" + "github.com/onflow/flow-go/ledger/common/pathfinder" + "github.com/onflow/flow-go/ledger/common/testutils" + "github.com/onflow/flow-go/ledger/complete" + "github.com/onflow/flow-go/ledger/complete/wal/fixtures" + "github.com/onflow/flow-go/module/metrics" +) + +// newPayloadlessLedger constructs a default payloadless ledger for tests. +func newPayloadlessLedger(t *testing.T) *complete.PayloadlessLedger { + t.Helper() + l, err := complete.NewPayloadlessLedger(100, &metrics.NoopCollector{}, zerolog.Logger{}, complete.DefaultPathFinderVersion) + require.NoError(t, err) + return l +} + +// newFullLedger constructs a full ledger backed by a NoopWAL for tests. +// A NoopCompactor is started and stopped via t.Cleanup so the ledger's +// trie update channel is drained. +func newFullLedger(t *testing.T) *complete.Ledger { + t.Helper() + wal := &fixtures.NoopWAL{} + l, err := complete.NewLedger(wal, 100, &metrics.NoopCollector{}, zerolog.Logger{}, complete.DefaultPathFinderVersion) + require.NoError(t, err) + compactor := fixtures.NewNoopCompactor(l) + <-compactor.Ready() + t.Cleanup(func() { + <-l.Done() + <-compactor.Done() + }) + return l +} + +// expectedLeafHash returns HashLeaf(path(key), value). This is the height-0 +// commitment that the payloadless trie stores at the leaf for the given key. +func expectedLeafHash(t *testing.T, key ledger.Key, value ledger.Value) hash.Hash { + t.Helper() + path, err := pathfinder.KeyToPath(key, complete.DefaultPathFinderVersion) + require.NoError(t, err) + return hash.HashLeaf(hash.Hash(path), []byte(value)) +} + +// TestNewPayloadlessLedger verifies the constructor succeeds and the +// resulting ledger is immediately ready (no WAL replay phase). +func TestNewPayloadlessLedger(t *testing.T) { + l := newPayloadlessLedger(t) + + // Ready and Done are no-ops; both channels should already be closed. + select { + case <-l.Ready(): + default: + t.Fatal("Ready() channel should be closed immediately") + } + select { + case <-l.Done(): + default: + t.Fatal("Done() channel should be closed immediately") + } +} + +func TestPayloadlessLedger_Set(t *testing.T) { + t.Run("empty update returns same state", func(t *testing.T) { + l := newPayloadlessLedger(t) + + state := l.InitialState() + up, err := ledger.NewEmptyUpdate(state) + require.NoError(t, err) + + newState, trieUpdate, err := l.Set(up) + require.NoError(t, err) + require.True(t, trieUpdate.IsEmpty()) + assert.Equal(t, state, newState) + }) + + t.Run("non-empty update advances state", func(t *testing.T) { + l := newPayloadlessLedger(t) + state := l.InitialState() + + u := testutils.UpdateFixture() + u.SetState(state) + + newState, trieUpdate, err := l.Set(u) + require.NoError(t, err) + assert.NotEqual(t, state, newState) + assert.False(t, trieUpdate.IsEmpty()) + assert.True(t, l.HasState(newState)) + }) +} + +func TestPayloadlessLedger_HasPaths(t *testing.T) { + l := newPayloadlessLedger(t) + state := l.InitialState() + + u := testutils.UpdateFixture() + u.SetState(state) + newState, _, err := l.Set(u) + require.NoError(t, err) + + // Allocated keys report true. + q, err := ledger.NewQuery(newState, u.Keys()) + require.NoError(t, err) + exists, err := l.HasPaths(q) + require.NoError(t, err) + require.Len(t, exists, len(u.Keys())) + for i, has := range exists { + assert.Truef(t, has, "key %d should report allocated", i) + } + + // Random unrelated keys report false. + unallocated := testutils.RandomUniqueKeys(5, 2, 1, 10) + q2, err := ledger.NewQuery(newState, unallocated) + require.NoError(t, err) + exists2, err := l.HasPaths(q2) + require.NoError(t, err) + for i, has := range exists2 { + assert.Falsef(t, has, "unallocated key %d should report unallocated", i) + } +} + +func TestPayloadlessLedger_GetSingleLeafHash(t *testing.T) { + l := newPayloadlessLedger(t) + state := l.InitialState() + + u := testutils.UpdateFixture() + u.SetState(state) + newState, _, err := l.Set(u) + require.NoError(t, err) + + t.Run("allocated key returns expected leaf hash", func(t *testing.T) { + for i, k := range u.Keys() { + q, err := ledger.NewQuerySingleValue(newState, k) + require.NoError(t, err) + got, err := l.GetSingleLeafHash(q) + require.NoError(t, err) + require.NotNilf(t, got, "key %d should have a leaf hash", i) + + expected := expectedLeafHash(t, k, u.Values()[i]) + assert.Equal(t, expected, *got) + } + }) + + t.Run("unallocated key returns nil", func(t *testing.T) { + unallocated := testutils.RandomUniqueKeys(3, 2, 1, 10) + for _, k := range unallocated { + q, err := ledger.NewQuerySingleValue(newState, k) + require.NoError(t, err) + got, err := l.GetSingleLeafHash(q) + require.NoError(t, err) + assert.Nil(t, got) + } + }) +} + +func TestPayloadlessLedger_GetLeafHashes(t *testing.T) { + l := newPayloadlessLedger(t) + state := l.InitialState() + + u := testutils.UpdateFixture() + u.SetState(state) + newState, _, err := l.Set(u) + require.NoError(t, err) + + t.Run("allocated keys return expected leaf hashes in order", func(t *testing.T) { + q, err := ledger.NewQuery(newState, u.Keys()) + require.NoError(t, err) + got, err := l.GetLeafHashes(q) + require.NoError(t, err) + require.Len(t, got, len(u.Keys())) + + for i, k := range u.Keys() { + require.NotNilf(t, got[i], "key %d should have a leaf hash", i) + expected := expectedLeafHash(t, k, u.Values()[i]) + assert.Equal(t, expected, *got[i]) + } + }) + + t.Run("unallocated keys return nil entries", func(t *testing.T) { + unallocated := testutils.RandomUniqueKeys(3, 2, 1, 10) + q, err := ledger.NewQuery(newState, unallocated) + require.NoError(t, err) + got, err := l.GetLeafHashes(q) + require.NoError(t, err) + require.Len(t, got, len(unallocated)) + for i, h := range got { + assert.Nilf(t, h, "unallocated key %d should produce nil", i) + } + }) +} + +func TestPayloadlessLedger_Prove(t *testing.T) { + l := newPayloadlessLedger(t) + state := l.InitialState() + + u := testutils.UpdateFixture() + u.SetState(state) + newState, _, err := l.Set(u) + require.NoError(t, err) + + q, err := ledger.NewQuery(newState, u.Keys()) + require.NoError(t, err) + batch, err := l.Prove(q) + require.NoError(t, err) + require.NotNil(t, batch) + assert.Equal(t, len(u.Keys()), batch.Size()) + + // Each inclusion proof should carry a leaf hash that matches HashLeaf(path, value). + expectedByPath := make(map[ledger.Path]hash.Hash, len(u.Keys())) + for i, k := range u.Keys() { + path, err := pathfinder.KeyToPath(k, complete.DefaultPathFinderVersion) + require.NoError(t, err) + expectedByPath[path] = expectedLeafHash(t, k, u.Values()[i]) + } + for i, p := range batch.Proofs { + require.Truef(t, p.Inclusion, "proof %d should be inclusion", i) + require.NotNilf(t, p.LeafHash, "proof %d should carry a leaf hash", i) + expected, ok := expectedByPath[p.Path] + require.Truef(t, ok, "proof %d path not in expected set", i) + assert.Equalf(t, expected, *p.LeafHash, "proof %d leaf hash mismatch", i) + } +} + +// Equivalence tests: drive complete.Ledger and complete.PayloadlessLedger +// through identical inputs and verify their observable outputs agree. + +// TestPayloadlessLedger_Equivalence_EmptyState verifies both implementations +// report the same initial root hash. +func TestPayloadlessLedger_Equivalence_EmptyState(t *testing.T) { + full := newFullLedger(t) + pl := newPayloadlessLedger(t) + + assert.Equal(t, full.InitialState(), pl.InitialState()) +} + +// TestPayloadlessLedger_Equivalence_Set verifies a Set with the same Update +// produces the same resulting state on both ledgers. +func TestPayloadlessLedger_Equivalence_Set(t *testing.T) { + full := newFullLedger(t) + pl := newPayloadlessLedger(t) + + state := full.InitialState() + uFull := testutils.UpdateFixture() + uFull.SetState(state) + uPL := testutils.UpdateFixture() + uPL.SetState(state) + + fullNew, _, err := full.Set(uFull) + require.NoError(t, err) + + plNew, _, err := pl.Set(uPL) + require.NoError(t, err) + + assert.Equal(t, fullNew, plNew, "states should agree after identical update") + assert.True(t, full.HasState(fullNew)) + assert.True(t, pl.HasState(plNew)) +} + +// TestPayloadlessLedger_Equivalence_Reads verifies that for every allocated +// key, the payloadless leaf hash equals HashLeaf(path, fullLedgerValue). +func TestPayloadlessLedger_Equivalence_Reads(t *testing.T) { + full := newFullLedger(t) + pl := newPayloadlessLedger(t) + + state := full.InitialState() + uFull := testutils.UpdateFixture() + uFull.SetState(state) + uPL := testutils.UpdateFixture() + uPL.SetState(state) + + fullNew, _, err := full.Set(uFull) + require.NoError(t, err) + plNew, _, err := pl.Set(uPL) + require.NoError(t, err) + require.Equal(t, fullNew, plNew) + + // Compare each allocated key. + fullQ, err := ledger.NewQuery(fullNew, uFull.Keys()) + require.NoError(t, err) + values, err := full.Get(fullQ) + require.NoError(t, err) + + plQ, err := ledger.NewQuery(plNew, uFull.Keys()) + require.NoError(t, err) + leafHashes, err := pl.GetLeafHashes(plQ) + require.NoError(t, err) + + require.Equal(t, len(uFull.Keys()), len(values)) + require.Equal(t, len(uFull.Keys()), len(leafHashes)) + for i, k := range uFull.Keys() { + require.NotNil(t, leafHashes[i]) + expected := expectedLeafHash(t, k, values[i]) + assert.Equalf(t, expected, *leafHashes[i], "key %d: payloadless leaf hash must equal HashLeaf(path, fullValue)", i) + } +} + +// TestPayloadlessLedger_Equivalence_HasPaths verifies that HasPaths agrees +// with the full ledger's ValueSizes>0 for the same query. +func TestPayloadlessLedger_Equivalence_HasPaths(t *testing.T) { + full := newFullLedger(t) + pl := newPayloadlessLedger(t) + + state := full.InitialState() + uFull := testutils.UpdateFixture() + uFull.SetState(state) + uPL := testutils.UpdateFixture() + uPL.SetState(state) + + fullNew, _, err := full.Set(uFull) + require.NoError(t, err) + plNew, _, err := pl.Set(uPL) + require.NoError(t, err) + require.Equal(t, fullNew, plNew) + + // Mix of allocated and unallocated keys. + queryKeys := append([]ledger.Key{}, uFull.Keys()...) + queryKeys = append(queryKeys, testutils.RandomUniqueKeys(5, 2, 1, 10)...) + + fullQ, err := ledger.NewQuery(fullNew, queryKeys) + require.NoError(t, err) + sizes, err := full.ValueSizes(fullQ) + require.NoError(t, err) + + plQ, err := ledger.NewQuery(plNew, queryKeys) + require.NoError(t, err) + exists, err := pl.HasPaths(plQ) + require.NoError(t, err) + + require.Equal(t, len(queryKeys), len(sizes)) + require.Equal(t, len(queryKeys), len(exists)) + for i := range queryKeys { + assert.Equalf(t, sizes[i] > 0, exists[i], "key %d: HasPaths should agree with ValueSizes>0", i) + } +} + +// TestPayloadlessLedger_Equivalence_IncrementalUpdates verifies state +// agreement across multiple rounds of updates. +func TestPayloadlessLedger_Equivalence_IncrementalUpdates(t *testing.T) { + full := newFullLedger(t) + pl := newPayloadlessLedger(t) + + fullState := full.InitialState() + plState := pl.InitialState() + require.Equal(t, fullState, plState) + + for round := 1; round <= 5; round++ { + uFull := testutils.UpdateFixture() + uFull.SetState(fullState) + uPL := testutils.UpdateFixture() + uPL.SetState(plState) + + var err error + fullState, _, err = full.Set(uFull) + require.NoErrorf(t, err, "round %d full.Set", round) + plState, _, err = pl.Set(uPL) + require.NoErrorf(t, err, "round %d pl.Set", round) + + require.Equalf(t, fullState, plState, "round %d: states diverged", round) + } +} diff --git a/ledger/partial/ptrie/partialTrie.go b/ledger/partial/ptrie/partialTrie.go index b011f299bd0..d9665171cbd 100644 --- a/ledger/partial/ptrie/partialTrie.go +++ b/ledger/partial/ptrie/partialTrie.go @@ -146,7 +146,7 @@ func NewPSMT( // check if the rootHash matches the root node's hash value of the partial trie if ledger.RootHash(psmt.root.forceComputeHash()) != rootValue { - return nil, fmt.Errorf("rootNode hash doesn't match the proofs expected [%x], got [%x]", psmt.root.Hash(), rootValue) + return nil, fmt.Errorf("rootNode hash doesn't match the proofs expected [%v], got [%v]", psmt.root.Hash(), rootValue) } return &psmt, nil }