diff --git a/transaction.go b/transaction.go index e09699e7..8d45c676 100644 --- a/transaction.go +++ b/transaction.go @@ -163,8 +163,9 @@ type TransactionOption interface { } type transactionOptions struct { - payer PublicKey - addressTables map[PublicKey]PublicKeySlice // [tablePubkey]addresses + payer PublicKey + addressTables map[PublicKey]PublicKeySlice // [tablePubkey]addresses + deterministicOrdering bool // sort lookup tables for deterministic serialization } type transactionOptionFunc func(opts *transactionOptions) @@ -181,6 +182,14 @@ func TransactionAddressTables(tables map[PublicKey]PublicKeySlice) TransactionOp return transactionOptionFunc(func(opts *transactionOptions) { opts.addressTables = tables }) } +// TransactionDeterministicOrdering ensures deterministic transaction serialization +// when using multiple address lookup tables. Without this option, map iteration +// order may cause account indices to vary between runs. Enable this for testing, +// caching, or any scenario requiring consistent transaction bytes. +func TransactionDeterministicOrdering() TransactionOption { + return transactionOptionFunc(func(opts *transactionOptions) { opts.deterministicOrdering = true }) +} + var debugNewTransaction = false type TransactionBuilder struct { @@ -233,6 +242,21 @@ type addressTablePubkeyWithIndex struct { index uint8 } +// sortedMapKeys returns the keys of a map, optionally sorted by byte comparison. +// When sortKeys is false, returns keys in arbitrary map iteration order. +func sortedMapKeys[V any](m map[PublicKey]V, sortKeys bool) []PublicKey { + keys := make([]PublicKey, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + if sortKeys { + sort.SliceStable(keys, func(i, j int) bool { + return bytes.Compare(keys[i][:], keys[j][:]) < 0 + }) + } + return keys +} + func NewTransaction(instructions []Instruction, recentBlockHash Hash, opts ...TransactionOption) (*Transaction, error) { if len(instructions) == 0 { return nil, fmt.Errorf("requires at-least one instruction to create a transaction") @@ -259,7 +283,11 @@ func NewTransaction(instructions []Instruction, recentBlockHash Hash, opts ...Tr } addressLookupKeysMap := make(map[PublicKey]addressTablePubkeyWithIndex) // all accounts from tables as map - for addressTablePubKey, addressTable := range options.addressTables { + + // When deterministicOrdering is enabled, sort keys for consistent behavior + // when addresses appear in multiple tables (first table in sorted order wins). + for _, addressTablePubKey := range sortedMapKeys(options.addressTables, options.deterministicOrdering) { + addressTable := options.addressTables[addressTablePubKey] if len(addressTable) > 256 { return nil, fmt.Errorf("max lookup table index exceeded for %s table", addressTablePubKey) } @@ -412,7 +440,10 @@ func NewTransaction(instructions []Instruction, recentBlockHash Hash, opts ...Tr if len(lookupsMap) > 0 { lookups := make([]MessageAddressTableLookup, 0, len(lookupsMap)) - for tablePubKey, l := range lookupsMap { + // When deterministicOrdering is enabled, sort keys for consistent + // transaction serialization across runs. + for _, tablePubKey := range sortedMapKeys(lookupsMap, options.deterministicOrdering) { + l := lookupsMap[tablePubKey] lookupsWritableKeys = append(lookupsWritableKeys, l.Writable...) lookupsReadOnlyKeys = append(lookupsReadOnlyKeys, l.Readonly...) diff --git a/transaction_test.go b/transaction_test.go index e1c19fe1..b4a48b37 100644 --- a/transaction_test.go +++ b/transaction_test.go @@ -399,3 +399,74 @@ func BenchmarkTransactionVerifySignatures(b *testing.B) { tx.VerifySignatures() } } + +// TestNewTransactionWithAddressLookupTables_Deterministic verifies that transaction +// serialization is deterministic when using TransactionDeterministicOrdering with +// multiple address lookup tables. +func TestNewTransactionWithAddressLookupTables_Deterministic(t *testing.T) { + // Define two lookup tables + lookupTable1 := MustPublicKeyFromBase58("8Vaso6eE1pWktDHwy2qQBB1fhjmBgwzhoXQKe1sxtFjn") + lookupTable2 := MustPublicKeyFromBase58("FqtwFavD9v99FvoaZrY14bGatCQa9ChsMVphEUNAWHeG") + + // Addresses unique to lookup table 1 + addr1InTable1 := MustPublicKeyFromBase58("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA") + addr2InTable1 := MustPublicKeyFromBase58("JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4") + + // Addresses unique to lookup table 2 + addr1InTable2 := MustPublicKeyFromBase58("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb") + addr2InTable2 := MustPublicKeyFromBase58("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL") + + // Shared address that exists in BOTH tables - tests that deterministic + // ordering consistently picks the same table when an address appears in multiple + sharedAddr := MustPublicKeyFromBase58("So11111111111111111111111111111111111111112") + + // Fee payer and program + feePayer := MustPublicKeyFromBase58("7KxrPswMgoVFwC1K7KmudEW9KzmVUCdBnLBMaSAjYQGp") + programID := MustPublicKeyFromBase58("11111111111111111111111111111111") + + addressTables := map[PublicKey]PublicKeySlice{ + lookupTable1: {addr1InTable1, addr2InTable1, sharedAddr}, + lookupTable2: {sharedAddr, addr1InTable2, addr2InTable2}, // sharedAddr at different index + } + + // Create instruction that uses accounts from both lookup tables, including the shared address + instruction := &testTransactionInstructions{ + accounts: []*AccountMeta{ + {PublicKey: feePayer, IsSigner: true, IsWritable: true}, + {PublicKey: addr1InTable1, IsSigner: false, IsWritable: false}, + {PublicKey: addr2InTable1, IsSigner: false, IsWritable: true}, + {PublicKey: sharedAddr, IsSigner: false, IsWritable: true}, // shared address + {PublicKey: addr1InTable2, IsSigner: false, IsWritable: false}, + {PublicKey: addr2InTable2, IsSigner: false, IsWritable: true}, + }, + data: []byte{0x01, 0x02, 0x03}, + programID: programID, + } + + blockhash := MustHashFromBase58("4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZAMdL4VZHirAn") + + // Build the transaction multiple times WITH deterministic ordering + // and verify consistent serialization + var firstSerialized []byte + for i := 0; i < 100; i++ { + tx, err := NewTransaction( + []Instruction{instruction}, + blockhash, + TransactionPayer(feePayer), + TransactionAddressTables(addressTables), + TransactionDeterministicOrdering(), // Enable deterministic ordering + ) + require.NoError(t, err) + + serialized, err := tx.MarshalBinary() + require.NoError(t, err) + + if i == 0 { + firstSerialized = serialized + } else { + // Every iteration should produce identical bytes + assert.Equal(t, firstSerialized, serialized, + "Transaction serialization should be deterministic (iteration %d)", i) + } + } +}