From 99c8d3e64d5423d80d04b83e31a147cdb04182c0 Mon Sep 17 00:00:00 2001 From: cavemanloverboy <93507302+cavemanloverboy@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:27:56 +0000 Subject: [PATCH 1/2] return an error when block accounts fail to load --- pkg/replay/block.go | 2 +- pkg/replay/streaming_lifecycle_test.go | 40 +++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/pkg/replay/block.go b/pkg/replay/block.go index 9b7bf651..ba54754f 100644 --- a/pkg/replay/block.go +++ b/pkg/replay/block.go @@ -4350,7 +4350,7 @@ func ProcessBlock( accts, parentAccts, accountMapCapacity, bankSysvars, err := loadBlockAccountsAndUpdateSysvars(exec.blockSrc, block, epochSchedule, alpenglowClock, parentBankSysvars, planner) loadAcctsRegion.End() if err != nil { - panic(fmt.Sprintf("unable to load slot accounts and update sysvars: %s", err)) + return nil, fmt.Errorf("load slot accounts and update sysvars at slot %d: %w", block.Slot, err) } if err := bankSysvars.ValidateForExecution(); err != nil { return nil, fmt.Errorf("invalid bank sysvar snapshot at slot %d: %w", block.Slot, err) diff --git a/pkg/replay/streaming_lifecycle_test.go b/pkg/replay/streaming_lifecycle_test.go index 6ee885ef..f887aa42 100644 --- a/pkg/replay/streaming_lifecycle_test.go +++ b/pkg/replay/streaming_lifecycle_test.go @@ -437,6 +437,13 @@ func systemTransferData(lamports uint64) []byte { // to entry 0 of lifecycleTableKey (a writable lookup): static keys are the // payer and the System program, so the destination is account index 2. func signedV0TransferViaTableWire(t *testing.T, seq uint64) []byte { + t.Helper() + return signedV0TransferViaTableIndexWire(t, seq, 0) +} + +// signedV0TransferViaTableIndexWire is signedV0TransferViaTableWire to entry +// index of the table. +func signedV0TransferViaTableIndexWire(t *testing.T, seq uint64, index uint8) []byte { t.Helper() msg := solana.Message{ Header: solana.MessageHeader{NumRequiredSignatures: 1, NumReadonlyUnsignedAccounts: 1}, @@ -447,7 +454,7 @@ func signedV0TransferViaTableWire(t *testing.T, seq uint64) []byte { Accounts: []uint16{0, 2}, Data: systemTransferData(1_000 + seq), }}, - AddressTableLookups: solana.MessageAddressTableLookupSlice{{AccountKey: lifecycleTableKey, WritableIndexes: []uint8{0}}}, + AddressTableLookups: solana.MessageAddressTableLookupSlice{{AccountKey: lifecycleTableKey, WritableIndexes: []uint8{index}}}, } _, err := msg.SetVersion(solana.MessageVersionV0) require.NoError(t, err) @@ -591,3 +598,34 @@ func TestStreamingLifecycleDiscardedV0PrefixResolvesAgainstTheNewParent(t *testi reference := lifecycleWholeBlock(t, newLifecycleEnvWithTable(t, destB), decodeTransactions(t, wires), 2) requireSameLifecycleOutcome(t, reference, whole) } + +// A block whose lookups cannot be resolved is rejected, not a node crash. +func TestProcessBlockRejectsUnresolvableLookups(t *testing.T) { + dest := solana.PublicKey{0xDA} + cases := []struct { + name string + table func(*accounts.Account) + index uint8 + }{ + {"uninitialized data", func(a *accounts.Account) { a.Data = make([]byte, sealevel.AddressLookupTableMetaSize) }, 0}, + {"undecodable data", func(a *accounts.Account) { a.Data = []byte{9, 9, 9} }, 0}, + {"index past the end", func(*accounts.Account) {}, 5}, + } + for _, tc := range cases { + for _, txParallelism := range []int{0, 4} { + env := newLifecycleEnvWithTable(t, dest) + table := lookupTableAccount(t, dest) + tc.table(table) + _ = env.durable.SetAccountWithoutLock(lifecycleTableKey, table) + txs := decodeTransactions(t, [][]byte{signedV0TransferViaTableIndexWire(t, 1, tc.index)}) + tail := &lifecycleTail{durable: env.durable} + block := env.block(txs) + block.MarkTransactionSignaturesVerified() + + slotCtx, err := ProcessBlock(env.acctsDb, block, env.epochSchedule, txParallelism, nil, &persistedTracker{}, tail, NewTransactionStatusCache(), false, env.parent) + require.Error(t, err, "%s txpar %d", tc.name, txParallelism) + require.Nil(t, slotCtx, "%s txpar %d", tc.name, txParallelism) + require.Empty(t, tail.added, "%s txpar %d: a rejected bank commits nothing", tc.name, txParallelism) + } + } +} From 735f519aa6b3ae217ae9d7ae52bfbce704313044 Mon Sep 17 00:00:00 2001 From: cavemanloverboy <93507302+cavemanloverboy@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:29:55 +0000 Subject: [PATCH 2/2] resolve address lookup tables by protocol rules in replay --- pkg/replay/address_lookup.go | 65 +++++++++++++++++++++++ pkg/replay/block.go | 72 ++++++++++++++++++++------ pkg/replay/block_execution.go | 6 ++- pkg/replay/block_execution_test.go | 7 ++- pkg/replay/streaming_lifecycle_test.go | 68 ++++++++++++++++++------ 5 files changed, 186 insertions(+), 32 deletions(-) create mode 100644 pkg/replay/address_lookup.go diff --git a/pkg/replay/address_lookup.go b/pkg/replay/address_lookup.go new file mode 100644 index 00000000..ce72d44f --- /dev/null +++ b/pkg/replay/address_lookup.go @@ -0,0 +1,65 @@ +package replay + +import ( + "errors" + + "github.com/Overclock-Validator/mithril/pkg/accounts" + "github.com/Overclock-Validator/mithril/pkg/addresses" + "github.com/Overclock-Validator/mithril/pkg/sealevel" + "github.com/gagliardetto/solana-go" +) + +var ( + TxErrAddressLookupTableNotFound = errors.New("TxErrAddressLookupTableNotFound") + TxErrInvalidAddressLookupTableOwner = errors.New("TxErrInvalidAddressLookupTableOwner") + TxErrInvalidAddressLookupTableData = errors.New("TxErrInvalidAddressLookupTableData") + TxErrInvalidAddressLookupTableIndex = errors.New("TxErrInvalidAddressLookupTableIndex") +) + +// activeLookupTable applies the Solana protocol's table rules to a table +// account as read for slot, judging deactivation against the SlotHashes of +// the bank executing at slot. It returns the table's active addresses: +// +// - a missing table, or a deactivated one, fails with AddressLookupTableNotFound; +// - a table not owned by the address lookup table program fails with +// InvalidAddressLookupTableOwner, and one that does not decode with +// InvalidAddressLookupTableData. +func activeLookupTable(acct *accounts.Account, slot uint64, slotHashes sealevel.SysvarSlotHashes) (solana.PublicKeySlice, *TransactionError) { + if acct == nil || acct.Lamports == 0 { + return nil, &TransactionError{ErrorType: TransactionErrorAddressLookupTableNotFound, InstructionError: TxErrAddressLookupTableNotFound} + } + if acct.Owner != addresses.AddressLookupTableAddr { + return nil, &TransactionError{ErrorType: TransactionErrorInvalidAddressLookupTableOwner, InstructionError: TxErrInvalidAddressLookupTableOwner} + } + table, err := sealevel.UnmarshalAddressLookupTable(acct.Data) + if err != nil { + return nil, &TransactionError{ErrorType: TransactionErrorInvalidAddressLookupTableData, InstructionError: TxErrInvalidAddressLookupTableData} + } + // A deactivated table may be closed at any point, so it is reported as + // missing rather than with an error of its own. + if table.Meta.Status(slot, slotHashes).Status == sealevel.AddressLookupTableStatusTypeDeactivated { + return nil, &TransactionError{ErrorType: TransactionErrorAddressLookupTableNotFound, InstructionError: TxErrAddressLookupTableNotFound} + } + + activeLen := len(table.Addresses) + if slot <= table.Meta.LastExtendedSlot { + activeLen = int(table.Meta.LastExtendedSlotStartIndex) + } + if activeLen > len(table.Addresses) { + return nil, &TransactionError{ErrorType: TransactionErrorInvalidAddressLookupTableData, InstructionError: TxErrInvalidAddressLookupTableData} + } + return table.Addresses[:activeLen], nil +} + +// checkLookupIndexes fails with InvalidAddressLookupTableIndex if the lookup +// uses an index at or past the table's active length. +func checkLookupIndexes(active solana.PublicKeySlice, lookup *solana.MessageAddressTableLookup) *TransactionError { + for _, indexes := range [2][]uint8{lookup.WritableIndexes, lookup.ReadonlyIndexes} { + for _, idx := range indexes { + if int(idx) >= len(active) { + return &TransactionError{ErrorType: TransactionErrorInvalidAddressLookupTableIndex, InstructionError: TxErrInvalidAddressLookupTableIndex} + } + } + } + return nil +} diff --git a/pkg/replay/block.go b/pkg/replay/block.go index ba54754f..9cc0b1e5 100644 --- a/pkg/replay/block.go +++ b/pkg/replay/block.go @@ -268,7 +268,12 @@ func serializeAllEpochStakes() map[uint64][]byte { return result } -func resolveAddrTableLookups(accountsDb blockAccountSource, block *b.Block) error { +// resolveAddrTableLookups resolves the block's lookups in place. Tables are +// read from the parent state at block.Slot and judged with slotHashes, the +// SlotHashes of the block's own bank; reading the parent gives the same +// active addresses the slot's own state would. A lookup that fails to resolve +// makes the transaction unprocessable and the block invalid. +func resolveAddrTableLookups(accountsDb blockAccountSource, block *b.Block, slotHashes sealevel.SysvarSlotHashes) error { tables := make(map[solana.PublicKey]solana.PublicKeySlice) for _, tx := range block.Transactions { @@ -280,6 +285,9 @@ func resolveAddrTableLookups(accountsDb blockAccountSource, block *b.Block) erro tables[addrTableKey] = nil } } + if len(tables) == 0 { + return nil + } tablesSlice := make([]solana.PublicKey, 0, len(tables)) for t := range tables { @@ -290,26 +298,28 @@ func resolveAddrTableLookups(accountsDb blockAccountSource, block *b.Block) erro return err } - for i := range tablesSlice { - if len(accts[i].Data) == 0 { - delete(tables, tablesSlice[i]) + tableErrs := make(map[solana.PublicKey]*TransactionError) + for i, key := range tablesSlice { + active, txErr := activeLookupTable(accts[i], block.Slot, slotHashes) + if txErr != nil { + tableErrs[key] = txErr continue } - addrLookupTable, err := sealevel.UnmarshalAddressLookupTable(accts[i].Data) - if err != nil { - return err - } - tables[tablesSlice[i]] = addrLookupTable.Addresses + tables[key] = active } -txResolveLoop: - for _, tx := range block.Transactions { + for idx, tx := range block.Transactions { if tx.Message.GetVersion() != solana.MessageVersionV0 || tx.Message.AddressTableLookups.NumLookups() == 0 { continue } - for _, addrTableKey := range tx.Message.GetAddressTableLookups().GetTableIDs() { - if _, ok := tables[addrTableKey]; !ok { - continue txResolveLoop + for i := range tx.Message.AddressTableLookups { + lookup := &tx.Message.AddressTableLookups[i] + txErr := tableErrs[lookup.AccountKey] + if txErr == nil { + txErr = checkLookupIndexes(tables[lookup.AccountKey], lookup) + } + if txErr != nil { + return fmt.Errorf("resolve address table lookups for transaction %d: %w", idx, txErr.InstructionError) } } err := tx.Message.SetAddressTables(tables) @@ -559,6 +569,34 @@ func recordSysvarAccountReadStats(dst *metrics.AccountLoader, src accountsdb.Acc } } +// blockSlotHashes returns the SlotHashes of the block's bank before its +// sysvars are installed: the parent's, from the same sources the SlotHashes +// update below uses, with the parent slot's hash at the front, exactly as +// that update and PrepareLeaderSlotSysvars derive them. +func blockSlotHashes(accountsDb blockAccountSource, block *b.Block, parentBankSysvars *sealevel.BankSysvars) (sealevel.SysvarSlotHashes, error) { + var slotHashes sealevel.SysvarSlotHashes + switch { + case parentBankSysvars != nil: + parentSlotHashes, ok := parentBankSysvars.SlotHashes() + if !ok { + return nil, errors.New("SlotHashes sysvar is absent from parent bank snapshot") + } + slotHashes = append(slotHashes, parentSlotHashes...) + case sealevel.SysvarCache.SlotHashes.Sysvar != nil: + slotHashes = append(slotHashes, *sealevel.SysvarCache.SlotHashes.Sysvar...) + default: + acct, err := accountsDb.GetAccount(block.Slot, sealevel.SysvarSlotHashesAddr) + if err != nil { + return nil, fmt.Errorf("load SlotHashes sysvar: %w", err) + } + if err := slotHashes.UnmarshalWithDecoder(bin.NewBinDecoder(acct.Data)); err != nil { + return nil, fmt.Errorf("decode SlotHashes sysvar: %w", err) + } + } + slotHashes.Update(block.Slot, block.ParentSlot, block.ParentBankhash) + return slotHashes, nil +} + func loadBlockAccountsAndUpdateSysvars( accountsDb blockAccountSource, block *b.Block, @@ -569,7 +607,11 @@ func loadBlockAccountsAndUpdateSysvars( ) (accounts.Accounts, accounts.Accounts, int, *sealevel.BankSysvars, error) { var bankSysvars *sealevel.BankSysvars phaseStart := time.Now() - err := resolveAddrTableLookups(accountsDb, block) + slotHashes, err := blockSlotHashes(accountsDb, block, parentBankSysvars) + if err != nil { + return nil, nil, 0, bankSysvars, err + } + err = resolveAddrTableLookups(accountsDb, block, slotHashes) metrics.GlobalBlockReplay.AccountLoader.AddressTableLookups.AddTimingSince(phaseStart) if err != nil { return nil, nil, 0, bankSysvars, err diff --git a/pkg/replay/block_execution.go b/pkg/replay/block_execution.go index da6484b4..bca295bf 100644 --- a/pkg/replay/block_execution.go +++ b/pkg/replay/block_execution.go @@ -390,7 +390,11 @@ func (exec *blockExecution) captureAccountLoader() func() { func (exec *blockExecution) loadTransactionAccounts(view *b.Block) error { defer exec.captureAccountLoader()() phaseStart := time.Now() - if err := resolveAddrTableLookups(exec.blockSrc, view); err != nil { + slotHashes, ok := exec.bankSysvars.SlotHashes() + if !ok { + return fmt.Errorf("resolve address table lookups at slot %d: bank has no SlotHashes sysvar", view.Slot) + } + if err := resolveAddrTableLookups(exec.blockSrc, view, slotHashes); err != nil { return fmt.Errorf("resolve address table lookups at slot %d: %w", view.Slot, err) } metrics.GlobalBlockReplay.AccountLoader.AddressTableLookups.AddTimingSince(phaseStart) diff --git a/pkg/replay/block_execution_test.go b/pkg/replay/block_execution_test.go index 31556fdf..a83f744c 100644 --- a/pkg/replay/block_execution_test.go +++ b/pkg/replay/block_execution_test.go @@ -91,6 +91,10 @@ func newGroupExecutionEnv(t *testing.T, txParallelism int, payerLamports uint64) VoteTimestamps: make(map[solana.PublicKey]sealevel.BlockTimestamp), Replay: true, } + slotHashes := sealevel.SysvarSlotHashes{{Slot: block.Slot - 1, Hash: [32]byte{0x61}}} + bankSysvars, err := sealevel.NewBankSysvars(block.Slot, + &accounts.Account{Key: sealevel.SysvarSlotHashesAddr, Lamports: 1, Data: slotHashes.MustMarshal()}) + require.NoError(t, err) source := &memBlockSource{mem: durable} exec := &blockExecution{ block: block, @@ -99,6 +103,7 @@ func newGroupExecutionEnv(t *testing.T, txParallelism int, payerLamports uint64) slotCtx: slotCtx, parentAccts: parent, accts: overlay, + bankSysvars: bankSysvars, ctx: context.Background(), setReplayStage: func(string) {}, seenMessages: make(map[[32]byte]int), @@ -327,7 +332,7 @@ func TestStreamingGroupRejectsUnresolvedLookupsBeforeExecution(t *testing.T) { defer env.cleanup() txs := decodeTransactions(t, [][]byte{signedV0TransferViaTableWire(t, 1)}) err := env.exec.executeTransactionGroup(txs, nil, false) - require.ErrorContains(t, err, "unresolved address tables") + require.ErrorIs(t, err, TxErrAddressLookupTableNotFound) require.Zero(t, env.exec.slotCtx.TotalComputeUnitsConsumed) } } diff --git a/pkg/replay/streaming_lifecycle_test.go b/pkg/replay/streaming_lifecycle_test.go index f887aa42..e0d2dd2c 100644 --- a/pkg/replay/streaming_lifecycle_test.go +++ b/pkg/replay/streaming_lifecycle_test.go @@ -599,33 +599,71 @@ func TestStreamingLifecycleDiscardedV0PrefixResolvesAgainstTheNewParent(t *testi requireSameLifecycleOutcome(t, reference, whole) } -// A block whose lookups cannot be resolved is rejected, not a node crash. -func TestProcessBlockRejectsUnresolvableLookups(t *testing.T) { +// Every lookup-table case gets the Solana protocol's verdict from ProcessBlock +// (both tx loops) and from a streaming group. Tables are judged against the +// block's SlotHashes: the parent's ([6] here) with the parent slot (7) added. +func TestReplayResolvesLookupTablesByProtocolRules(t *testing.T) { dest := solana.PublicKey{0xDA} + deactivatedAt := func(slot uint64) func(*accounts.Account) { + return func(a *accounts.Account) { binary.LittleEndian.PutUint64(a.Data[4:12], slot) } + } cases := []struct { name string table func(*accounts.Account) index uint8 + want error // nil: the block is accepted }{ - {"uninitialized data", func(a *accounts.Account) { a.Data = make([]byte, sealevel.AddressLookupTableMetaSize) }, 0}, - {"undecodable data", func(a *accounts.Account) { a.Data = []byte{9, 9, 9} }, 0}, - {"index past the end", func(*accounts.Account) {}, 5}, + {"active", func(*accounts.Account) {}, 0, nil}, + {"deactivating in the current slot", deactivatedAt(lifecycleSlot), 0, nil}, + {"deactivated at the parent slot", deactivatedAt(lifecycleParentSlot), 0, nil}, + {"deactivating, still in SlotHashes", deactivatedAt(lifecycleParentSlot - 1), 0, nil}, + {"deactivated, gone from SlotHashes", deactivatedAt(2), 0, TxErrAddressLookupTableNotFound}, + // The source's view of an absent account. + {"missing", func(a *accounts.Account) { *a = accounts.Account{Key: lifecycleTableKey} }, 0, TxErrAddressLookupTableNotFound}, + {"zero lamports with data", func(a *accounts.Account) { a.Lamports = 0 }, 0, TxErrAddressLookupTableNotFound}, + {"wrong owner", func(a *accounts.Account) { a.Owner = addresses.SystemProgramAddr }, 0, TxErrInvalidAddressLookupTableOwner}, + {"uninitialized data", func(a *accounts.Account) { a.Data = make([]byte, sealevel.AddressLookupTableMetaSize) }, 0, TxErrInvalidAddressLookupTableData}, + {"undecodable data", func(a *accounts.Account) { a.Data = []byte{9, 9, 9} }, 0, TxErrInvalidAddressLookupTableData}, + {"index past the end", func(*accounts.Account) {}, 5, TxErrInvalidAddressLookupTableIndex}, } - for _, tc := range cases { + newEnv := func(t *testing.T, tc int) (*lifecycleEnv, []*solana.Transaction) { + env := newLifecycleEnvWithTable(t, dest) + table := lookupTableAccount(t, dest) + cases[tc].table(table) + _ = env.durable.SetAccountWithoutLock(lifecycleTableKey, table) + return env, decodeTransactions(t, [][]byte{signedV0TransferViaTableIndexWire(t, 1, cases[tc].index)}) + } + for tc := range cases { + name, want := cases[tc].name, cases[tc].want for _, txParallelism := range []int{0, 4} { - env := newLifecycleEnvWithTable(t, dest) - table := lookupTableAccount(t, dest) - tc.table(table) - _ = env.durable.SetAccountWithoutLock(lifecycleTableKey, table) - txs := decodeTransactions(t, [][]byte{signedV0TransferViaTableIndexWire(t, 1, tc.index)}) + env, txs := newEnv(t, tc) tail := &lifecycleTail{durable: env.durable} block := env.block(txs) block.MarkTransactionSignaturesVerified() - slotCtx, err := ProcessBlock(env.acctsDb, block, env.epochSchedule, txParallelism, nil, &persistedTracker{}, tail, NewTransactionStatusCache(), false, env.parent) - require.Error(t, err, "%s txpar %d", tc.name, txParallelism) - require.Nil(t, slotCtx, "%s txpar %d", tc.name, txParallelism) - require.Empty(t, tail.added, "%s txpar %d: a rejected bank commits nothing", tc.name, txParallelism) + if want == nil { + require.NoError(t, err, "%s txpar %d", name, txParallelism) + require.Equal(t, uint64(10_000_000+1_001), lifecycleOutcomeOf(t, slotCtx, tail).delta[dest].Lamports, "%s txpar %d", name, txParallelism) + continue + } + require.ErrorIs(t, err, want, "%s txpar %d", name, txParallelism) + require.Nil(t, slotCtx, "%s txpar %d", name, txParallelism) + require.Empty(t, tail.added, "%s txpar %d: a rejected bank commits nothing", name, txParallelism) + } + + env, txs := newEnv(t, tc) + exec := newBlockExecution(env.acctsDb, env.block(nil), env.epochSchedule, 2, nil, &persistedTracker{}, &lifecycleTail{durable: env.durable}, NewTransactionStatusCache(), false, env.parent) + require.NoError(t, exec.open()) + err := exec.executeTransactionGroup(txs, nil, false) + exec.close() + if want == nil { + require.NoError(t, err, "%s streaming", name) + destAcct, err := exec.slotCtx.GetAccountShared(dest) + require.NoError(t, err) + require.Equal(t, uint64(10_000_000+1_001), destAcct.Lamports, "%s streaming", name) + continue } + require.ErrorIs(t, err, want, "%s streaming", name) + require.Zero(t, exec.slotCtx.TotalComputeUnitsConsumed, "%s streaming: nothing executed", name) } }