From ef0c587aa717bce193554004f3d122417f772da7 Mon Sep 17 00:00:00 2001 From: cavemanloverboy <93507302+cavemanloverboy@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:58:47 +0000 Subject: [PATCH] blockprod: resolve address lookup tables for v0 transactions --- pkg/blockprod/bank.go | 32 +- pkg/blockprod/bank_lookup_test.go | 139 +++++++ pkg/blockprod/scheduler/scheduler.go | 3 + .../scheduler/scheduler_lookup_test.go | 73 ++++ pkg/blockprod/testenv_lookup.go | 111 +++++ pkg/costmodel/costmodel_test.go | 43 ++ pkg/costmodel/transaction_cost.go | 23 +- pkg/forge/forge.go | 3 + pkg/replay/address_lookup.go | 96 +++++ pkg/replay/address_lookup_test.go | 378 ++++++++++++++++++ pkg/replay/transaction_preparation.go | 5 + 11 files changed, 899 insertions(+), 7 deletions(-) create mode 100644 pkg/blockprod/bank_lookup_test.go create mode 100644 pkg/blockprod/scheduler/scheduler_lookup_test.go create mode 100644 pkg/blockprod/testenv_lookup.go create mode 100644 pkg/replay/address_lookup_test.go diff --git a/pkg/blockprod/bank.go b/pkg/blockprod/bank.go index 4f1d809b..5cd101f3 100644 --- a/pkg/blockprod/bank.go +++ b/pkg/blockprod/bank.go @@ -152,6 +152,7 @@ const ( ForgeDroppedExecution ForgeDroppedAlreadyProcessed ForgeDroppedAccountLocks + ForgeDroppedLookup ) func (r ForgeResult) String() string { @@ -172,6 +173,8 @@ func (r ForgeResult) String() string { return "dropped_already_processed" case ForgeDroppedAccountLocks: return "dropped_account_locks" + case ForgeDroppedLookup: + return "dropped_lookup" default: return "unknown" } @@ -246,8 +249,29 @@ func (b *WorkingBank) forgeTransaction(tx *solana.Transaction, wireSize int, pre ancestorAlreadyProcessed, ancestorStatusErr = b.ancestorStatuses.ContainsMessage(tx.Message.RecentBlockhash, messageHash) } + // For a v0 transaction with address-table lookups, execTx is a resolved copy, + // while tx stays unresolved for entry encoding. + execTx := tx var cost costmodel.TransactionCost if !ancestorAlreadyProcessed && ancestorStatusErr == nil { + if replay.HasAddressTableLookups(tx) { + // Tables are read from the parent state, which this slot does not + // change, so resolve outside the bank lock. + resolved, txErr := replay.ResolveAddressTableLookups(b.slotCtx, tx) + if txErr != nil { + b.RebateSchedule(wireSize) + return ForgeDroppedLookup, costmodel.ExceedNone + } + // A loaded address can repeat a static key or another loaded + // address, or take the account count past the lock limit. + if txErr := replay.ValidateAccountLocks(resolved, lockFeats); txErr != nil { + b.RebateSchedule(wireSize) + return ForgeDroppedAccountLocks, costmodel.ExceedNone + } + execTx = resolved + // Static preparation, if any, was done on the unresolved message. + prepared = nil + } feats := b.slotCtx.Features if feats == nil { f := features.NewFeaturesDefault() @@ -256,7 +280,7 @@ func (b *WorkingBank) forgeTransaction(tx *solana.Transaction, wireSize int, pre if prepared != nil { cost = prepared.Cost() } else { - cost, err = costmodel.EstimateTransactionCost(tx, feats) + cost, err = costmodel.EstimateTransactionCost(execTx, feats) } if err != nil { b.RebateSchedule(wireSize) @@ -294,19 +318,19 @@ func (b *WorkingBank) forgeTransaction(tx *solana.Transaction, wireSize int, pre if reason := b.reserveEntryBytesLocked(wireSize); reason != costmodel.ExceedNone { return ForgeDroppedCost, reason } - if err := b.preparer.PayerCanFund(b.slotCtx, tx, prepared); err != nil { + if err := b.preparer.PayerCanFund(b.slotCtx, execTx, prepared); err != nil { return ForgeDroppedExecution, costmodel.ExceedNone } output := b.preparer.LoadAndExecute(replay.LoadAndExecuteTransactionInput{ SlotCtx: b.slotCtx, - Transaction: tx, + Transaction: execTx, LeanResult: true, SkipTimingMetrics: true, Arena: b.borrowedAccounts, }, prepared) if output.ProcessingResult.TransactionError != nil { - feeInfo, err := replay.ApplyFailedTransaction(b.slotCtx, tx, output) + feeInfo, err := replay.ApplyFailedTransaction(b.slotCtx, execTx, output) if err != nil { return ForgeDroppedExecution, costmodel.ExceedNone } diff --git a/pkg/blockprod/bank_lookup_test.go b/pkg/blockprod/bank_lookup_test.go new file mode 100644 index 00000000..8df5b9f7 --- /dev/null +++ b/pkg/blockprod/bank_lookup_test.go @@ -0,0 +1,139 @@ +package blockprod + +import ( + "testing" + + "github.com/Overclock-Validator/mithril/pkg/costmodel" + "github.com/Overclock-Validator/mithril/pkg/sealevel" + "github.com/Overclock-Validator/mithril/pkg/tpu/txfixture" + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var lookupTestTableKey = solana.PublicKey{0x7A, 0xB1, 0xE0} + +// withLookupSlotHashes installs SlotHashes for the test env's slot, which +// lookup resolution needs to classify deactivating tables. +func withLookupSlotHashes(t *testing.T, env *TestEnv) { + t.Helper() + prev := sealevel.SysvarCache.SlotHashes.Sysvar + slot := env.SlotCtx.Slot + slotHashes := sealevel.SysvarSlotHashes{{Slot: slot - 1}, {Slot: slot - 2}} + sealevel.SysvarCache.SlotHashes.Sysvar = &slotHashes + t.Cleanup(func() { sealevel.SysvarCache.SlotHashes.Sysvar = prev }) +} + +// addLookupTable puts table in the test bank's parent state, which is where +// lookups are resolved from. +func addLookupTable(t *testing.T, env *TestEnv, table LookupTable) { + t.Helper() + acct, err := table.Account() + require.NoError(t, err) + parent, ok := env.SlotCtx.UnrootedRead.(ParentAccounts) + if !ok { + parent = ParentAccounts{} + env.SlotCtx.UnrootedRead = parent + } + parent[table.Key] = acct +} + +func lookupTransfer(t *testing.T, lamports uint64) (*solana.Transaction, []byte) { + t.Helper() + wire, err := SignedV0LookupTransferWire(lamports, lookupTestTableKey, 0) + require.NoError(t, err) + tx, err := solana.TransactionFromBytes(wire) + require.NoError(t, err) + return tx, wire +} + +func lamportsOf(t *testing.T, env *TestEnv, key solana.PublicKey) uint64 { + t.Helper() + acct, err := env.SlotCtx.GetAccount(key) + require.NoError(t, err) + return acct.Lamports +} + +func TestWorkingBankForgesV0TransferThroughLookupTable(t *testing.T) { + env := NewTestEnv(TestEnvConfig{}) + defer env.Close() + withLookupSlotHashes(t, env) + addLookupTable(t, env, ActiveLookupTable(lookupTestTableKey, txfixture.DestPubkey())) + + const amount = 1_234 + tx, wire := lookupTransfer(t, amount) + payerBefore := lamportsOf(t, env, txfixture.PayerPubkey()) + destBefore := lamportsOf(t, env, txfixture.DestPubkey()) + + result, reason := env.Bank.ForgeTransaction(tx, len(wire)) + require.Equal(t, ForgeAccepted, result) + require.Equal(t, costmodel.ExceedNone, reason) + + assert.Equal(t, destBefore+amount, lamportsOf(t, env, txfixture.DestPubkey())) + assert.Equal(t, payerBefore-amount-5_000, lamportsOf(t, env, txfixture.PayerPubkey())) + assert.Equal(t, uint64(5_000), env.Bank.TxFeeAccumulator().TotalFees) + + // The entry carries the original, unresolved transaction and its exact wire. + forged := env.Bank.ForgedTransactions() + require.Len(t, forged, 1) + assert.Same(t, tx, forged[0]) + assert.False(t, forged[0].Message.IsResolved()) + forgedWire, err := forged[0].MarshalBinary() + require.NoError(t, err) + assert.Equal(t, wire, forgedWire) + + // The looked-up destination is write-locked in block cost accounting. + assert.NotZero(t, env.Bank.CostTracker().WritableAccountCost(txfixture.DestPubkey())) +} + +func TestWorkingBankDropsUnresolvableLookups(t *testing.T) { + deactivated := ActiveLookupTable(lookupTestTableKey, txfixture.DestPubkey()) + deactivated.DeactivationSlot = 30 // no longer in SlotHashes + appendedThisSlot := ActiveLookupTable(lookupTestTableKey, txfixture.DestPubkey()) + appendedThisSlot.LastExtendedSlot = 42 // the test env's slot + appendedThisSlot.LastExtendedStartIndex = 0 + + cases := []struct { + name string + table *LookupTable + }{ + {"missing table", nil}, + {"deactivated table", &deactivated}, + {"address appended this slot", &appendedThisSlot}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + env := NewTestEnv(TestEnvConfig{}) + defer env.Close() + withLookupSlotHashes(t, env) + if tc.table != nil { + addLookupTable(t, env, *tc.table) + } + tx, wire := lookupTransfer(t, 1) + assertForgeLookupDropped(t, env, tx, wire, ForgeDroppedLookup) + }) + } +} + +// The account lock rules apply to the resolved keys too: a table that loads +// the transaction's own payer repeats a static key. +func TestWorkingBankDropsLookupThatRepeatsAStaticKey(t *testing.T) { + env := NewTestEnv(TestEnvConfig{}) + defer env.Close() + withLookupSlotHashes(t, env) + addLookupTable(t, env, ActiveLookupTable(lookupTestTableKey, txfixture.PayerPubkey())) + tx, wire := lookupTransfer(t, 1) + assertForgeLookupDropped(t, env, tx, wire, ForgeDroppedAccountLocks) +} + +func assertForgeLookupDropped(t *testing.T, env *TestEnv, tx *solana.Transaction, wire []byte, want ForgeResult) { + t.Helper() + payerBefore := lamportsOf(t, env, txfixture.PayerPubkey()) + result, reason := env.Bank.ForgeTransaction(tx, len(wire)) + assert.Equal(t, want, result) + assert.Equal(t, costmodel.ExceedNone, reason) + assert.Equal(t, payerBefore, lamportsOf(t, env, txfixture.PayerPubkey())) + assert.Empty(t, env.Bank.ForgedTransactions()) + assert.Zero(t, env.Bank.TxFeeAccumulator().TotalFees) + assert.False(t, tx.Message.IsResolved(), "the scheduler's transaction must stay unresolved") +} diff --git a/pkg/blockprod/scheduler/scheduler.go b/pkg/blockprod/scheduler/scheduler.go index 60d63ba3..2985a70c 100644 --- a/pkg/blockprod/scheduler/scheduler.go +++ b/pkg/blockprod/scheduler/scheduler.go @@ -32,6 +32,7 @@ type Stats struct { DroppedExpired uint64 DroppedAlreadyProcessed uint64 DroppedAccountLocks uint64 + DroppedLookup uint64 Accepted uint64 DroppedNoBank uint64 DroppedCost uint64 @@ -365,6 +366,8 @@ func (s *Scheduler) recordForge(result blockprod.ForgeResult, reason costmodel.E s.stats.DroppedAlreadyProcessed++ case blockprod.ForgeDroppedAccountLocks: s.stats.DroppedAccountLocks++ + case blockprod.ForgeDroppedLookup: + s.stats.DroppedLookup++ default: s.stats.DroppedNoBank++ } diff --git a/pkg/blockprod/scheduler/scheduler_lookup_test.go b/pkg/blockprod/scheduler/scheduler_lookup_test.go new file mode 100644 index 00000000..6a5605d1 --- /dev/null +++ b/pkg/blockprod/scheduler/scheduler_lookup_test.go @@ -0,0 +1,73 @@ +package scheduler + +import ( + "context" + "testing" + "time" + + "github.com/Overclock-Validator/mithril/pkg/blockprod" + "github.com/Overclock-Validator/mithril/pkg/sealevel" + "github.com/Overclock-Validator/mithril/pkg/tpu/packet" + "github.com/Overclock-Validator/mithril/pkg/tpu/txfixture" + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/require" +) + +var schedulerLookupTableKey = solana.PublicKey{0x7A, 0xB1, 0xE1} + +// lookupEnv is a test bank at slot whose lookup table had its only address +// appended in slot 42. +func lookupEnv(t *testing.T, slot uint64) *blockprod.TestEnv { + t.Helper() + env := blockprod.NewTestEnv(blockprod.TestEnvConfig{}) + t.Cleanup(env.Close) + env.SlotCtx.Slot = slot + table := blockprod.ActiveLookupTable(schedulerLookupTableKey, txfixture.DestPubkey()) + table.LastExtendedSlot = 42 + acct, err := table.Account() + require.NoError(t, err) + env.SlotCtx.UnrootedRead = blockprod.ParentAccounts{table.Key: acct} + return env +} + +func waitFor(t *testing.T, what string, cond func() bool) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for !cond() { + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for %s", what) + } + time.Sleep(2 * time.Millisecond) + } +} + +// A transaction whose lookup does not resolve is dropped, not retried, even +// though it would resolve in a later slot. +func TestSchedulerDropsFailedLookupWithoutRetry(t *testing.T) { + prev := sealevel.SysvarCache.SlotHashes.Sysvar + slotHashes := sealevel.SysvarSlotHashes{{Slot: 42}, {Slot: 41}} + sealevel.SysvarCache.SlotHashes.Sysvar = &slotHashes + t.Cleanup(func() { sealevel.SysvarCache.SlotHashes.Sysvar = prev }) + + controller := blockprod.NewController() + sched := New(controller) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + sched.Start(ctx) + t.Cleanup(sched.Stop) + wire, err := blockprod.SignedV0LookupTransferWire(1, schedulerLookupTableKey, 0) + require.NoError(t, err) + sched.Receive(packet.Owned(wire)) + waitFor(t, "buffering", func() bool { return sched.Buffered() == 1 }) + + controller.SetWorkingBank(lookupEnv(t, 42).Bank) + waitFor(t, "lookup failure", func() bool { return sched.Stats().DroppedLookup == 1 }) + + next := lookupEnv(t, 43) + controller.SetWorkingBank(next.Bank) + time.Sleep(50 * time.Millisecond) + require.Zero(t, sched.Stats().Accepted) + require.Empty(t, next.Bank.ForgedTransactions()) + require.Zero(t, sched.Buffered()) + require.Equal(t, uint64(1), sched.Stats().DroppedLookup) +} diff --git a/pkg/blockprod/testenv_lookup.go b/pkg/blockprod/testenv_lookup.go new file mode 100644 index 00000000..cae7f3e2 --- /dev/null +++ b/pkg/blockprod/testenv_lookup.go @@ -0,0 +1,111 @@ +package blockprod + +import ( + "bytes" + "encoding/binary" + "fmt" + "math" + + "github.com/Overclock-Validator/mithril/pkg/accounts" + "github.com/Overclock-Validator/mithril/pkg/addresses" + "github.com/Overclock-Validator/mithril/pkg/sealevel" + "github.com/Overclock-Validator/mithril/pkg/tpu/txfixture" + bin "github.com/gagliardetto/binary" + "github.com/gagliardetto/solana-go" +) + +// ParentAccounts serves accounts from a test bank's parent state. Install it +// as the bank's SlotCtx.UnrootedRead for accounts the leader reads from its +// parent rather than from the slot, such as address lookup tables. +type ParentAccounts map[solana.PublicKey]*accounts.Account + +func (p ParentAccounts) GetAccount(_ uint64, pubkey solana.PublicKey) (*accounts.Account, error) { + if acct, ok := p[pubkey]; ok { + return acct.Clone(), nil + } + return nil, fmt.Errorf("account %s not found in parent state", pubkey) +} + +// LookupTable describes an address lookup table account for tests. +type LookupTable struct { + Key solana.PublicKey + // DeactivationSlot is math.MaxUint64 for an active table. + DeactivationSlot uint64 + // LastExtendedSlot and LastExtendedStartIndex record the most recent + // extension: addresses from LastExtendedStartIndex on were appended in + // LastExtendedSlot. + LastExtendedSlot uint64 + LastExtendedStartIndex uint8 + Addresses []solana.PublicKey +} + +// ActiveLookupTable is an active table last extended in slot 1. +func ActiveLookupTable(key solana.PublicKey, addrs ...solana.PublicKey) LookupTable { + return LookupTable{Key: key, DeactivationSlot: math.MaxUint64, LastExtendedSlot: 1, Addresses: addrs} +} + +// Account encodes the table the way the address lookup table program stores +// it, with the fixture payer as authority. +func (lt LookupTable) Account() (*accounts.Account, error) { + var buf bytes.Buffer + enc := bin.NewBinEncoder(&buf) + if err := enc.WriteUint32(sealevel.AddressLookupTableProgramStateLookupTable, bin.LE); err != nil { + return nil, err + } + authority := txfixture.PayerPubkey() + meta := sealevel.LookupTableMeta{ + DeactivationSlot: lt.DeactivationSlot, + LastExtendedSlot: lt.LastExtendedSlot, + LastExtendedSlotStartIndex: lt.LastExtendedStartIndex, + Authority: &authority, + } + if err := meta.MarshalWithEncoder(enc); err != nil { + return nil, err + } + for _, addr := range lt.Addresses { + if err := enc.WriteBytes(addr[:], false); err != nil { + return nil, err + } + } + return &accounts.Account{ + Key: lt.Key, + Lamports: 1_000_000_000, + Owner: addresses.AddressLookupTableAddr, + Data: buf.Bytes(), + RentEpoch: math.MaxUint64, + }, nil +} + +// SignedV0LookupTransferWire is a signed v0 transfer of lamports from the +// fixture payer to entry destIndex of table, loaded as a writable lookup. Its +// static keys are [payer, system program]. +func SignedV0LookupTransferWire(lamports uint64, table solana.PublicKey, destIndex uint8) ([]byte, error) { + data := make([]byte, 12) + binary.LittleEndian.PutUint32(data, 2) // SystemInstruction::Transfer + binary.LittleEndian.PutUint64(data[4:], lamports) + msg := solana.Message{ + Header: solana.MessageHeader{NumRequiredSignatures: 1, NumReadonlyUnsignedAccounts: 1}, + AccountKeys: solana.PublicKeySlice{txfixture.PayerPubkey(), solana.SystemProgramID}, + RecentBlockhash: txfixture.TestBlockhash(), + Instructions: []solana.CompiledInstruction{{ + ProgramIDIndex: 1, + Accounts: []uint16{0, 2}, + Data: data, + }}, + AddressTableLookups: solana.MessageAddressTableLookupSlice{{AccountKey: table, WritableIndexes: []uint8{destIndex}}}, + } + if _, err := msg.SetVersion(solana.MessageVersionV0); err != nil { + return nil, err + } + tx := &solana.Transaction{Message: msg} + payerKey := txfixture.PayerPrivateKey() + if _, err := tx.Sign(func(key solana.PublicKey) *solana.PrivateKey { + if key.Equals(txfixture.PayerPubkey()) { + return &payerKey + } + return nil + }); err != nil { + return nil, err + } + return tx.MarshalBinary() +} diff --git a/pkg/costmodel/costmodel_test.go b/pkg/costmodel/costmodel_test.go index 82160999..241c39ba 100644 --- a/pkg/costmodel/costmodel_test.go +++ b/pkg/costmodel/costmodel_test.go @@ -94,6 +94,49 @@ func TestWritableAccountsUsesUnsignedWritableRange(t *testing.T) { assert.Equal(t, []solana.PublicKey{{1}, {3}, {4}}, writableAccounts(tx)) } +// v0LookupTx has static keys {1} (writable signer), {2} (writable), {3} +// (readonly), and loads two writable and one readonly address from a table. +func v0LookupTx(t *testing.T) *solana.Transaction { + t.Helper() + msg := solana.Message{ + Header: solana.MessageHeader{ + NumRequiredSignatures: 1, + NumReadonlyUnsignedAccounts: 1, + }, + AccountKeys: solana.PublicKeySlice{{1}, {2}, {3}}, + Instructions: []solana.CompiledInstruction{{ProgramIDIndex: 2, Accounts: []uint16{0, 1, 3, 4, 5}}}, + AddressTableLookups: solana.MessageAddressTableLookupSlice{{ + AccountKey: solana.PublicKey{9}, + WritableIndexes: []uint8{0, 1}, + ReadonlyIndexes: []uint8{2}, + }}, + } + _, err := msg.SetVersion(solana.MessageVersionV0) + require.NoError(t, err) + return &solana.Transaction{Signatures: []solana.Signature{{}}, Message: msg} +} + +func TestWritableAccountsIncludesWritableLookups(t *testing.T) { + tx := v0LookupTx(t) + // Before resolution only the static keys are known. + assert.Equal(t, []solana.PublicKey{{1}, {2}}, writableAccounts(tx)) + + require.NoError(t, tx.Message.SetAddressTables(map[solana.PublicKey]solana.PublicKeySlice{ + {9}: {{0xA}, {0xB}, {0xC}}, + })) + require.NoError(t, tx.Message.ResolveLookups()) + require.Equal(t, solana.PublicKeySlice{{1}, {2}, {3}, {0xA}, {0xB}, {0xC}}, tx.Message.AccountKeys) + + // The header still describes the static keys only: {3} stays readonly, + // the two writable lookups are added and the readonly one is not. + assert.Equal(t, []solana.PublicKey{{1}, {2}, {0xA}, {0xB}}, writableAccounts(tx)) + + cost, err := EstimateTransactionCost(tx, features.NewFeaturesDefault()) + require.NoError(t, err) + assert.Equal(t, uint64(4*WriteLockUnits), cost.WriteLockCost) + assert.Equal(t, []solana.PublicKey{{1}, {2}, {0xA}, {0xB}}, cost.WritableAccounts) +} + func TestEstimateTransactionCostV1UsesInlineLimits(t *testing.T) { msg := solana.Message{ Header: solana.MessageHeader{ diff --git a/pkg/costmodel/transaction_cost.go b/pkg/costmodel/transaction_cost.go index 3a804b03..038c6ac2 100644 --- a/pkg/costmodel/transaction_cost.go +++ b/pkg/costmodel/transaction_cost.go @@ -134,6 +134,10 @@ func countWriteLocks(tx *solana.Transaction) uint64 { return uint64(len(writableAccounts(tx))) } +// writableAccounts returns the accounts tx write-locks. For a v0 message with +// resolved address-table lookups, the header describes only the static keys, +// and the writable looked-up addresses follow them; an unresolved message only +// contributes its static keys, so cost it after resolution. func writableAccounts(tx *solana.Transaction) []solana.PublicKey { if tx == nil { return nil @@ -141,8 +145,21 @@ func writableAccounts(tx *solana.Transaction) []solana.PublicKey { hdr := tx.Message.Header numSigners := int(hdr.NumRequiredSignatures) numKeys := len(tx.Message.AccountKeys) + var writableLookups []solana.PublicKey + if tx.Message.GetVersion() == solana.MessageVersionV0 && tx.Message.IsResolved() { + numStatic := numKeys - tx.Message.AddressTableLookups.NumLookups() + if numStatic < 0 { + numStatic = 0 + } + lookupEnd := numStatic + tx.Message.AddressTableLookups.NumWritableLookups() + if lookupEnd > numKeys { + lookupEnd = numKeys + } + writableLookups = tx.Message.AccountKeys[numStatic:lookupEnd] + numKeys = numStatic + } if numKeys == 0 { - return nil + return append([]solana.PublicKey(nil), writableLookups...) } numWritableSigners := numSigners - int(hdr.NumReadonlySignedAccounts) if numWritableSigners < 0 { @@ -152,7 +169,7 @@ func writableAccounts(tx *solana.Transaction) []solana.PublicKey { if numWritableNonSigners < 0 { numWritableNonSigners = 0 } - out := make([]solana.PublicKey, 0, numWritableSigners+numWritableNonSigners) + out := make([]solana.PublicKey, 0, numWritableSigners+numWritableNonSigners+len(writableLookups)) for i := 0; i < numWritableSigners && i < numKeys; i++ { out = append(out, tx.Message.AccountKeys[i]) } @@ -164,7 +181,7 @@ func writableAccounts(tx *solana.Transaction) []solana.PublicKey { for i := start; i < end; i++ { out = append(out, tx.Message.AccountKeys[i]) } - return out + return append(out, writableLookups...) } func estimateAllocDelta(instrs []sealevel.Instruction, feats *features.Features) uint64 { diff --git a/pkg/forge/forge.go b/pkg/forge/forge.go index ba994494..ad9f09ae 100644 --- a/pkg/forge/forge.go +++ b/pkg/forge/forge.go @@ -25,6 +25,7 @@ type Stats struct { DroppedExecution uint64 DroppedAlreadyProcessed uint64 DroppedAccountLocks uint64 + DroppedLookup uint64 DroppedBlockCost uint64 DroppedAccountCost uint64 DroppedAllocCost uint64 @@ -82,6 +83,8 @@ func (s *Sink) Receive(pkt packet.Packet) { s.stats.DroppedAlreadyProcessed++ case blockprod.ForgeDroppedAccountLocks: s.stats.DroppedAccountLocks++ + case blockprod.ForgeDroppedLookup: + s.stats.DroppedLookup++ default: s.stats.DroppedNoBank++ } diff --git a/pkg/replay/address_lookup.go b/pkg/replay/address_lookup.go index ce72d44f..e542ac78 100644 --- a/pkg/replay/address_lookup.go +++ b/pkg/replay/address_lookup.go @@ -63,3 +63,99 @@ func checkLookupIndexes(active solana.PublicKeySlice, lookup *solana.MessageAddr } return nil } + +// HasAddressTableLookups reports whether tx is a v0 transaction that loads +// accounts from address lookup tables. +func HasAddressTableLookups(tx *solana.Transaction) bool { + return tx != nil && tx.Message.GetVersion() == solana.MessageVersionV0 && tx.Message.AddressTableLookups.NumLookups() != 0 +} + +// ResolveAddressTableLookups returns a copy of tx whose address-table lookups +// are resolved for the slot of slotCtx, the bank the transaction executes in. +// +// Tables are read from the bank's parent state, never from writes made in the +// slot itself, and give the same addresses the slot's own state would: a +// table extended during the slot keeps the new addresses inactive until the +// slot has passed, because the address lookup table program records the +// address count at the slot's first extension as the active length; a table +// deactivated during the slot is still deactivating and usable; and a table +// created during the slot has no active address yet. Reading the parent also +// keeps the result independent of which transactions of the slot ran first. +// +// Lookups follow the Solana protocol's rules: +// +// - 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; +// - an index at or past the table's active length fails with +// InvalidAddressLookupTableIndex. +// +// tx is never modified: resolution appends the loaded addresses to the account +// keys, and the original must stay unresolved for entry encoding. The copy +// serializes identically to the original, since only static keys are encoded. +// Transactions without lookups are returned as is. +func ResolveAddressTableLookups(slotCtx *sealevel.SlotCtx, tx *solana.Transaction) (*solana.Transaction, *TransactionError) { + if !HasAddressTableLookups(tx) { + return tx, nil + } + if tx.Message.IsResolved() { + // Its loaded addresses came from some other bank, which cannot be + // vouched for here. + return nil, &TransactionError{ErrorType: TransactionErrorSanitizeFailure, InstructionError: TxErrSanitizeFailure} + } + slotHashes, ok := slotHashesForAddressLookups(slotCtx) + if !ok { + return nil, &TransactionError{ErrorType: TransactionErrorSanitizeFailure, InstructionError: TxErrSanitizeFailure} + } + + tables := make(map[solana.PublicKey]solana.PublicKeySlice, len(tx.Message.AddressTableLookups)) + for i := range tx.Message.AddressTableLookups { + lookup := &tx.Message.AddressTableLookups[i] + active, txErr := activeLookupTable(parentLookupTableAccount(slotCtx, lookup.AccountKey), slotCtx.Slot, slotHashes) + if txErr == nil { + txErr = checkLookupIndexes(active, lookup) + } + if txErr != nil { + return nil, txErr + } + tables[lookup.AccountKey] = active + } + + resolved := *tx + resolved.Message = tx.Message + resolved.Message.AccountKeys = append(solana.PublicKeySlice(nil), tx.Message.AccountKeys...) + if err := resolved.Message.SetAddressTables(tables); err != nil { + return nil, &TransactionError{ErrorType: TransactionErrorInvalidAddressLookupTableIndex, InstructionError: TxErrInvalidAddressLookupTableIndex} + } + if err := resolved.Message.ResolveLookups(); err != nil { + return nil, &TransactionError{ErrorType: TransactionErrorInvalidAddressLookupTableIndex, InstructionError: TxErrInvalidAddressLookupTableIndex} + } + return &resolved, nil +} + +// parentLookupTableAccount reads a table from slotCtx's parent state: the +// unrooted chain the bank was built on, then durable AccountsDb. It returns +// nil if the table does not exist there. +func parentLookupTableAccount(slotCtx *sealevel.SlotCtx, key solana.PublicKey) *accounts.Account { + if slotCtx.UnrootedRead == nil && slotCtx.AccountsDb == nil { + return nil + } + acct, err := slotCtx.GetAccountFromAccountsDb(key) + if err != nil { + return nil + } + return acct +} + +// slotHashesForAddressLookups prefers the bank's pinned SlotHashes, as the +// leader's bank carries them, and falls back to the process-wide sysvar cache. +func slotHashesForAddressLookups(slotCtx *sealevel.SlotCtx) (sealevel.SysvarSlotHashes, bool) { + if bankSysvars := slotCtx.BankSysvars(); bankSysvars != nil { + return bankSysvars.SlotHashes() + } + if sealevel.SysvarCache.SlotHashes.Sysvar == nil { + return nil, false + } + return *sealevel.SysvarCache.SlotHashes.Sysvar, true +} diff --git a/pkg/replay/address_lookup_test.go b/pkg/replay/address_lookup_test.go new file mode 100644 index 00000000..7ac5ee4f --- /dev/null +++ b/pkg/replay/address_lookup_test.go @@ -0,0 +1,378 @@ +package replay + +import ( + "bytes" + "context" + "fmt" + "math" + "testing" + + "github.com/Overclock-Validator/mithril/pkg/accounts" + "github.com/Overclock-Validator/mithril/pkg/addresses" + b "github.com/Overclock-Validator/mithril/pkg/block" + "github.com/Overclock-Validator/mithril/pkg/sealevel" + "github.com/Overclock-Validator/mithril/pkg/tpu/txfixture" + bin "github.com/gagliardetto/binary" + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// lookupTestSlot matches newCommitTestSlotCtx. +const lookupTestSlot = 42 + +func encodeLookupTable(t *testing.T, key solana.PublicKey, meta sealevel.LookupTableMeta, addrs ...solana.PublicKey) *accounts.Account { + t.Helper() + var buf bytes.Buffer + enc := bin.NewBinEncoder(&buf) + require.NoError(t, enc.WriteUint32(sealevel.AddressLookupTableProgramStateLookupTable, bin.LE)) + require.NoError(t, meta.MarshalWithEncoder(enc)) + for len(buf.Bytes()) < sealevel.AddressLookupTableMetaSize { + require.NoError(t, enc.WriteByte(0)) + } + for _, addr := range addrs { + require.NoError(t, enc.WriteBytes(addr[:], false)) + } + return &accounts.Account{Key: key, Lamports: 1_000_000, Owner: addresses.AddressLookupTableAddr, Data: buf.Bytes(), RentEpoch: math.MaxUint64} +} + +func activeTableMeta() sealevel.LookupTableMeta { + authority := txfixture.PayerPubkey() + return sealevel.LookupTableMeta{DeactivationSlot: math.MaxUint64, LastExtendedSlot: 1, Authority: &authority} +} + +func lookupTestAddresses(n int) []solana.PublicKey { + addrs := make([]solana.PublicKey, n) + for i := range addrs { + addrs[i] = solana.NewWallet().PublicKey() + } + return addrs +} + +// lookupTestTx is a v0 message with static keys [payer, system] and the given +// lookups; the instruction only has to make the message well formed. +func lookupTestTx(t *testing.T, lookups ...solana.MessageAddressTableLookup) *solana.Transaction { + t.Helper() + msg := solana.Message{ + Header: solana.MessageHeader{NumRequiredSignatures: 1, NumReadonlyUnsignedAccounts: 1}, + AccountKeys: solana.PublicKeySlice{txfixture.PayerPubkey(), solana.SystemProgramID}, + RecentBlockhash: txfixture.TestBlockhash(), + Instructions: []solana.CompiledInstruction{{ProgramIDIndex: 1, Accounts: []uint16{0}}}, + AddressTableLookups: lookups, + } + _, err := msg.SetVersion(solana.MessageVersionV0) + require.NoError(t, err) + tx := &solana.Transaction{Message: msg} + payerKey := txfixture.PayerPrivateKey() + _, err = tx.Sign(func(key solana.PublicKey) *solana.PrivateKey { + if key.Equals(txfixture.PayerPubkey()) { + return &payerKey + } + return nil + }) + require.NoError(t, err) + return tx +} + +// lookupParentState is a bank's parent state for lookup tests. It serves both +// as the slot's UnrootedRead and as replay's block account source. +type lookupParentState map[solana.PublicKey]*accounts.Account + +func (s lookupParentState) GetAccount(_ uint64, pubkey solana.PublicKey) (*accounts.Account, error) { + if acct, ok := s[pubkey]; ok { + return acct, nil + } + return nil, fmt.Errorf("account %s not found", pubkey) +} + +func (s lookupParentState) GetAccountsBatch(_ context.Context, slot uint64, pks []solana.PublicKey) ([]*accounts.Account, error) { + out := make([]*accounts.Account, len(pks)) + for i, pk := range pks { + out[i], _ = s.GetAccount(slot, pk) + } + return out, nil +} + +// newLookupTestSlotCtx is a bank at lookupTestSlot whose parent state holds +// tables. +func newLookupTestSlotCtx(t *testing.T, tables ...*accounts.Account) *sealevel.SlotCtx { + t.Helper() + slotCtx, cleanup := newCommitTestSlotCtx() + t.Cleanup(cleanup) + prev := sealevel.SysvarCache.SlotHashes.Sysvar + slotHashes := sealevel.SysvarSlotHashes{{Slot: lookupTestSlot - 1}, {Slot: lookupTestSlot - 2}} + sealevel.SysvarCache.SlotHashes.Sysvar = &slotHashes + t.Cleanup(func() { sealevel.SysvarCache.SlotHashes.Sysvar = prev }) + parent := lookupParentState{} + for _, table := range tables { + parent[table.Key] = table + } + slotCtx.UnrootedRead = parent + return slotCtx +} + +func requireLookupError(t *testing.T, want TransactionErrorType, wantErr error, resolved *solana.Transaction, got *TransactionError) { + t.Helper() + require.Nil(t, resolved) + require.NotNil(t, got) + assert.Equal(t, want, got.ErrorType, "got %s", got.ErrorType.String()) + assert.ErrorIs(t, got.InstructionError, wantErr) +} + +func TestResolveAddressTableLookupsOrdersWritableThenReadonly(t *testing.T) { + t1, t2 := solana.NewWallet().PublicKey(), solana.NewWallet().PublicKey() + a1, a2 := lookupTestAddresses(3), lookupTestAddresses(2) + slotCtx := newLookupTestSlotCtx(t, + encodeLookupTable(t, t1, activeTableMeta(), a1...), + encodeLookupTable(t, t2, activeTableMeta(), a2...)) + tx := lookupTestTx(t, + solana.MessageAddressTableLookup{AccountKey: t1, WritableIndexes: []uint8{2}, ReadonlyIndexes: []uint8{0}}, + solana.MessageAddressTableLookup{AccountKey: t2, WritableIndexes: []uint8{1}, ReadonlyIndexes: []uint8{0}}, + ) + wire, err := tx.MarshalBinary() + require.NoError(t, err) + + resolved, txErr := ResolveAddressTableLookups(slotCtx, tx) + require.Nil(t, txErr) + + static := tx.Message.AccountKeys + assert.Equal(t, solana.PublicKeySlice{static[0], static[1], a1[2], a2[1], a1[0], a2[0]}, resolved.Message.AccountKeys) + for i, want := range []bool{true, false, true, true, false, false} { + writable, err := resolved.Message.IsWritable(resolved.Message.AccountKeys[i]) + require.NoError(t, err) + assert.Equal(t, want, writable, "key %d", i) + } + + // The input is untouched and both serialize to the original wire. + assert.False(t, tx.Message.IsResolved()) + assert.Len(t, tx.Message.AccountKeys, 2) + gotWire, err := resolved.MarshalBinary() + require.NoError(t, err) + assert.Equal(t, wire, gotWire) + origWire, err := tx.MarshalBinary() + require.NoError(t, err) + assert.Equal(t, wire, origWire) +} + +func TestResolveAddressTableLookupsSameTableTwice(t *testing.T) { + table := solana.NewWallet().PublicKey() + addrs := lookupTestAddresses(2) + slotCtx := newLookupTestSlotCtx(t, encodeLookupTable(t, table, activeTableMeta(), addrs...)) + tx := lookupTestTx(t, + solana.MessageAddressTableLookup{AccountKey: table, WritableIndexes: []uint8{0}}, + solana.MessageAddressTableLookup{AccountKey: table, ReadonlyIndexes: []uint8{1}}, + ) + resolved, txErr := ResolveAddressTableLookups(slotCtx, tx) + require.Nil(t, txErr) + assert.Equal(t, addrs, []solana.PublicKey(resolved.Message.AccountKeys[2:])) +} + +func TestResolveAddressTableLookupsActivation(t *testing.T) { + cases := []struct { + name string + deactivationSlot uint64 + wantNotFound bool + }{ + {"activated", math.MaxUint64, false}, + {"deactivating in the current slot", lookupTestSlot, false}, + {"deactivating, still in SlotHashes", lookupTestSlot - 2, false}, + {"deactivated, gone from SlotHashes", lookupTestSlot - 10, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + table := solana.NewWallet().PublicKey() + meta := activeTableMeta() + meta.DeactivationSlot = tc.deactivationSlot + slotCtx := newLookupTestSlotCtx(t, encodeLookupTable(t, table, meta, lookupTestAddresses(1)...)) + tx := lookupTestTx(t, solana.MessageAddressTableLookup{AccountKey: table, ReadonlyIndexes: []uint8{0}}) + + resolved, txErr := ResolveAddressTableLookups(slotCtx, tx) + if tc.wantNotFound { + requireLookupError(t, TransactionErrorAddressLookupTableNotFound, TxErrAddressLookupTableNotFound, resolved, txErr) + return + } + require.Nil(t, txErr) + assert.Len(t, resolved.Message.AccountKeys, 3) + }) + } +} + +func TestResolveAddressTableLookupsExcludesAddressesAppendedThisSlot(t *testing.T) { + table := solana.NewWallet().PublicKey() + addrs := lookupTestAddresses(3) + extendedNow := activeTableMeta() + extendedNow.LastExtendedSlot = lookupTestSlot + extendedNow.LastExtendedSlotStartIndex = 2 + + slotCtx := newLookupTestSlotCtx(t, encodeLookupTable(t, table, extendedNow, addrs...)) + resolved, txErr := ResolveAddressTableLookups(slotCtx, lookupTestTx(t, solana.MessageAddressTableLookup{AccountKey: table, WritableIndexes: []uint8{1}})) + require.Nil(t, txErr, "addresses appended before this slot are active") + assert.Equal(t, addrs[1], resolved.Message.AccountKeys[2]) + + // Index 2 was appended in this slot, so it is not active yet. + resolved, txErr = ResolveAddressTableLookups(slotCtx, lookupTestTx(t, solana.MessageAddressTableLookup{AccountKey: table, ReadonlyIndexes: []uint8{2}})) + requireLookupError(t, TransactionErrorInvalidAddressLookupTableIndex, TxErrInvalidAddressLookupTableIndex, resolved, txErr) + + // Extended in the previous slot: every address is active. + extendedBefore := extendedNow + extendedBefore.LastExtendedSlot = lookupTestSlot - 1 + slotCtx = newLookupTestSlotCtx(t, encodeLookupTable(t, table, extendedBefore, addrs...)) + resolved, txErr = ResolveAddressTableLookups(slotCtx, lookupTestTx(t, solana.MessageAddressTableLookup{AccountKey: table, ReadonlyIndexes: []uint8{2}})) + require.Nil(t, txErr) + assert.Equal(t, addrs[2], resolved.Message.AccountKeys[2]) +} + +func TestResolveAddressTableLookupsRejectsBadTables(t *testing.T) { + table := solana.NewWallet().PublicKey() + good := encodeLookupTable(t, table, activeTableMeta(), lookupTestAddresses(2)...) + + zeroLamports := *good + zeroLamports.Lamports = 0 + wrongOwner := *good + wrongOwner.Owner = addresses.SystemProgramAddr + garbage := *good + garbage.Data = []byte{1, 2, 3} + badStart := activeTableMeta() + badStart.LastExtendedSlot = lookupTestSlot + badStart.LastExtendedSlotStartIndex = 9 + + cases := []struct { + name string + table *accounts.Account + index uint8 + want TransactionErrorType + wantErr error + }{ + {"missing table", nil, 0, TransactionErrorAddressLookupTableNotFound, TxErrAddressLookupTableNotFound}, + {"zero lamports", &zeroLamports, 0, TransactionErrorAddressLookupTableNotFound, TxErrAddressLookupTableNotFound}, + {"wrong owner", &wrongOwner, 0, TransactionErrorInvalidAddressLookupTableOwner, TxErrInvalidAddressLookupTableOwner}, + {"undecodable data", &garbage, 0, TransactionErrorInvalidAddressLookupTableData, TxErrInvalidAddressLookupTableData}, + {"active length past the end", encodeLookupTable(t, table, badStart, lookupTestAddresses(2)...), 0, TransactionErrorInvalidAddressLookupTableData, TxErrInvalidAddressLookupTableData}, + {"index out of range", good, 2, TransactionErrorInvalidAddressLookupTableIndex, TxErrInvalidAddressLookupTableIndex}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var tables []*accounts.Account + if tc.table != nil { + tables = append(tables, tc.table) + } + slotCtx := newLookupTestSlotCtx(t, tables...) + tx := lookupTestTx(t, solana.MessageAddressTableLookup{AccountKey: table, ReadonlyIndexes: []uint8{tc.index}}) + resolved, txErr := ResolveAddressTableLookups(slotCtx, tx) + requireLookupError(t, tc.want, tc.wantErr, resolved, txErr) + }) + } +} + +func TestResolveAddressTableLookupsPassesThroughAndRefusesResolved(t *testing.T) { + slotCtx := newLookupTestSlotCtx(t) + legacy, err := solana.TransactionFromBytes(txfixture.MustSignedTransferWire(0)) + require.NoError(t, err) + got, txErr := ResolveAddressTableLookups(slotCtx, legacy) + require.Nil(t, txErr) + assert.Same(t, legacy, got) + + table := solana.NewWallet().PublicKey() + tx := lookupTestTx(t, solana.MessageAddressTableLookup{AccountKey: table, ReadonlyIndexes: []uint8{0}}) + require.NoError(t, tx.Message.SetAddressTables(map[solana.PublicKey]solana.PublicKeySlice{table: lookupTestAddresses(1)})) + require.NoError(t, tx.Message.ResolveLookups()) + got, txErr = ResolveAddressTableLookups(slotCtx, tx) + requireLookupError(t, TransactionErrorSanitizeFailure, TxErrSanitizeFailure, got, txErr) +} + +// For an active table, resolving on the leader must give the account keys +// replay resolves for the same transaction in the produced block. +func TestResolveAddressTableLookupsMatchesReplayResolution(t *testing.T) { + t1, t2 := solana.NewWallet().PublicKey(), solana.NewWallet().PublicKey() + tables := []*accounts.Account{ + encodeLookupTable(t, t1, activeTableMeta(), lookupTestAddresses(4)...), + encodeLookupTable(t, t2, activeTableMeta(), lookupTestAddresses(3)...), + } + slotCtx := newLookupTestSlotCtx(t, tables...) + wire, err := lookupTestTx(t, + solana.MessageAddressTableLookup{AccountKey: t2, WritableIndexes: []uint8{2, 0}, ReadonlyIndexes: []uint8{1}}, + solana.MessageAddressTableLookup{AccountKey: t1, WritableIndexes: []uint8{3}, ReadonlyIndexes: []uint8{0, 2}}, + ).MarshalBinary() + require.NoError(t, err) + + leaderTx, err := solana.TransactionFromBytes(wire) + require.NoError(t, err) + leaderResolved, txErr := ResolveAddressTableLookups(slotCtx, leaderTx) + require.Nil(t, txErr) + + replayTx, err := solana.TransactionFromBytes(wire) + require.NoError(t, err) + source := lookupParentState{t1: tables[0], t2: tables[1]} + slotHashes, ok := slotHashesForAddressLookups(slotCtx) + require.True(t, ok) + require.NoError(t, resolveAddrTableLookups(source, &b.Block{Slot: lookupTestSlot, Transactions: []*solana.Transaction{replayTx}}, slotHashes)) + require.True(t, replayTx.Message.IsResolved()) + + assert.Equal(t, replayTx.Message.AccountKeys, leaderResolved.Message.AccountKeys) +} + +// Resolving from the parent copy of a table is only sound because whatever +// the slot does to the table cannot change lookups in that slot. The parent +// copy has 2 addresses; the slot then extends it twice to 4. Only the slot's +// first extension records the start index, at the pre-extension count, so +// both copies must give identical results at the executing slot. This fails +// if the address lookup table program ever moved the start index on later +// extensions of the same slot. +func TestLookupTableParentAndSlotCopiesAgree(t *testing.T) { + table := solana.NewWallet().PublicKey() + addrs := lookupTestAddresses(4) + parentMeta := activeTableMeta() + parentMeta.LastExtendedSlot = lookupTestSlot - 1 + parentCopy := encodeLookupTable(t, table, parentMeta, addrs[:2]...) + slotMeta := activeTableMeta() + slotMeta.LastExtendedSlot = lookupTestSlot + slotMeta.LastExtendedSlotStartIndex = 2 + slotCopy := encodeLookupTable(t, table, slotMeta, addrs...) + slotHashes := sealevel.SysvarSlotHashes{{Slot: lookupTestSlot - 1}, {Slot: lookupTestSlot - 2}} + resolve := func(acct *accounts.Account, lookup *solana.MessageAddressTableLookup) (solana.PublicKeySlice, *TransactionError) { + active, txErr := activeLookupTable(acct, lookupTestSlot, slotHashes) + if txErr == nil { + txErr = checkLookupIndexes(active, lookup) + } + if txErr != nil { + return nil, txErr + } + return active, nil + } + + for _, idx := range []uint8{0, 1, 2, 3} { + lookup := &solana.MessageAddressTableLookup{AccountKey: table, ReadonlyIndexes: []uint8{idx}} + fromParent, parentErr := resolve(parentCopy, lookup) + fromSlot, slotErr := resolve(slotCopy, lookup) + assert.Equal(t, parentErr, slotErr, "index %d", idx) + assert.Equal(t, fromParent, fromSlot, "index %d", idx) + if idx < 2 { + require.Nil(t, parentErr, "index %d", idx) + assert.Equal(t, addrs[idx], fromParent[idx]) + } else { + require.NotNil(t, parentErr, "index %d", idx) + assert.ErrorIs(t, parentErr.InstructionError, TxErrInvalidAddressLookupTableIndex) + } + } +} + +// Writes the slot itself made to a table are not read. +func TestResolveAddressTableLookupsIgnoresSlotWrites(t *testing.T) { + table := solana.NewWallet().PublicKey() + addrs := lookupTestAddresses(2) + + // Created in the slot: absent from the parent, so not found. + slotCtx := newLookupTestSlotCtx(t) + require.NoError(t, slotCtx.SetAccount(table, encodeLookupTable(t, table, activeTableMeta(), addrs...))) + resolved, txErr := ResolveAddressTableLookups(slotCtx, lookupTestTx(t, solana.MessageAddressTableLookup{AccountKey: table, ReadonlyIndexes: []uint8{0}})) + requireLookupError(t, TransactionErrorAddressLookupTableNotFound, TxErrAddressLookupTableNotFound, resolved, txErr) + + // Closed in the slot: the parent copy is what counts. + slotCtx = newLookupTestSlotCtx(t, encodeLookupTable(t, table, activeTableMeta(), addrs...)) + closed := encodeLookupTable(t, table, activeTableMeta(), addrs...) + closed.Lamports = 0 + require.NoError(t, slotCtx.SetAccount(table, closed)) + resolved, txErr = ResolveAddressTableLookups(slotCtx, lookupTestTx(t, solana.MessageAddressTableLookup{AccountKey: table, ReadonlyIndexes: []uint8{1}})) + require.Nil(t, txErr) + assert.Equal(t, addrs[1], resolved.Message.AccountKeys[2]) +} diff --git a/pkg/replay/transaction_preparation.go b/pkg/replay/transaction_preparation.go index 303dcc3e..8baded82 100644 --- a/pkg/replay/transaction_preparation.go +++ b/pkg/replay/transaction_preparation.go @@ -78,6 +78,11 @@ func (p *TransactionPreparer) Prepare(tx *solana.Transaction) *PreparedTransacti if p == nil || tx == nil { return nil } + // Account metas and cost depend on the bank the lookups resolve against, so + // a transaction with address-table lookups is prepared at execution. + if HasAddressTableLookups(tx) { + return nil + } if tx.Message.GetVersion() == solana.MessageVersionV1 && !p.feats.IsActive(features.EnableTxV1) { return nil }