From 2ea368d66ca53b543e41ce384332bbb001d5d2bf Mon Sep 17 00:00:00 2001 From: JDeuce Date: Mon, 15 Dec 2025 20:01:53 -0600 Subject: [PATCH 1/6] feat: implement TransactionDeterministicOrdering option --- transaction.go | 54 +++++++++++++++++++++++++++++++++++++------------- 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/transaction.go b/transaction.go index e09699e7..6e3f1676 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.Slice(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,20 +283,20 @@ 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) } - for i, address := range addressTable { - _, ok := addressLookupKeysMap[address] - if ok { - continue - } - - addressLookupKeysMap[address] = addressTablePubkeyWithIndex{ - addressTable: addressTablePubKey, - index: uint8(i), + if _, ok := addressLookupKeysMap[address]; !ok { + addressLookupKeysMap[address] = addressTablePubkeyWithIndex{ + addressTable: addressTablePubKey, + index: uint8(i), + } } } } @@ -412,10 +436,12 @@ 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...) - lookups = append(lookups, MessageAddressTableLookup{ AccountKey: tablePubKey, WritableIndexes: l.WritableIndexes, From 6f33fe74c968d8c0afe60696b485c54b39ee7307 Mon Sep 17 00:00:00 2001 From: JDeuce Date: Mon, 15 Dec 2025 20:02:05 -0600 Subject: [PATCH 2/6] test: add coverage for Deterministic sorting option --- transaction_test.go | 66 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/transaction_test.go b/transaction_test.go index e1c19fe1..744e3765 100644 --- a/transaction_test.go +++ b/transaction_test.go @@ -399,3 +399,69 @@ 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 with different addresses + lookupTable1 := MustPublicKeyFromBase58("8Vaso6eE1pWktDHwy2qQBB1fhjmBgwzhoXQKe1sxtFjn") + lookupTable2 := MustPublicKeyFromBase58("FqtwFavD9v99FvoaZrY14bGatCQa9ChsMVphEUNAWHeG") + + // Addresses in lookup table 1 + addr1InTable1 := MustPublicKeyFromBase58("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA") + addr2InTable1 := MustPublicKeyFromBase58("JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4") + + // Addresses in lookup table 2 + addr1InTable2 := MustPublicKeyFromBase58("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb") + addr2InTable2 := MustPublicKeyFromBase58("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL") + + // Fee payer and program + feePayer := MustPublicKeyFromBase58("7KxrPswMgoVFwC1K7KmudEW9KzmVUCdBnLBMaSAjYQGp") + programID := MustPublicKeyFromBase58("11111111111111111111111111111111") + + addressTables := map[PublicKey]PublicKeySlice{ + lookupTable1: {addr1InTable1, addr2InTable1}, + lookupTable2: {addr1InTable2, addr2InTable2}, + } + + // Create instruction that uses accounts from both lookup tables + instruction := &testTransactionInstructions{ + accounts: []*AccountMeta{ + {PublicKey: feePayer, IsSigner: true, IsWritable: true}, + {PublicKey: addr1InTable1, IsSigner: false, IsWritable: false}, + {PublicKey: addr2InTable1, IsSigner: false, IsWritable: true}, + {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) + } + } +} From d86b3a4a1152410e212b211113c95e832ca7d37c Mon Sep 17 00:00:00 2001 From: JDeuce Date: Tue, 16 Dec 2025 23:38:10 -0600 Subject: [PATCH 3/6] refactor: undo style change --- transaction.go | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/transaction.go b/transaction.go index 6e3f1676..c42535f5 100644 --- a/transaction.go +++ b/transaction.go @@ -291,12 +291,16 @@ func NewTransaction(instructions []Instruction, recentBlockHash Hash, opts ...Tr if len(addressTable) > 256 { return nil, fmt.Errorf("max lookup table index exceeded for %s table", addressTablePubKey) } + for i, address := range addressTable { - if _, ok := addressLookupKeysMap[address]; !ok { - addressLookupKeysMap[address] = addressTablePubkeyWithIndex{ - addressTable: addressTablePubKey, - index: uint8(i), - } + _, ok := addressLookupKeysMap[address] + if ok { + continue + } + + addressLookupKeysMap[address] = addressTablePubkeyWithIndex{ + addressTable: addressTablePubKey, + index: uint8(i), } } } From d5a95e86145ff4138944520ecc52529108309f79 Mon Sep 17 00:00:00 2001 From: JDeuce Date: Tue, 16 Dec 2025 23:37:51 -0600 Subject: [PATCH 4/6] use SliceStable to match codebase --- transaction.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/transaction.go b/transaction.go index c42535f5..b22ce195 100644 --- a/transaction.go +++ b/transaction.go @@ -250,7 +250,7 @@ func sortedMapKeys[V any](m map[PublicKey]V, sortKeys bool) []PublicKey { keys = append(keys, k) } if sortKeys { - sort.Slice(keys, func(i, j int) bool { + sort.SliceStable(keys, func(i, j int) bool { return bytes.Compare(keys[i][:], keys[j][:]) < 0 }) } From 7a210a7d14c9a381950419af2cc401f6e6de5a51 Mon Sep 17 00:00:00 2001 From: JDeuce Date: Tue, 16 Dec 2025 23:48:34 -0600 Subject: [PATCH 5/6] test: modify case to use shared address in both ALT --- transaction_test.go | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/transaction_test.go b/transaction_test.go index 744e3765..b4a48b37 100644 --- a/transaction_test.go +++ b/transaction_test.go @@ -404,33 +404,38 @@ func BenchmarkTransactionVerifySignatures(b *testing.B) { // serialization is deterministic when using TransactionDeterministicOrdering with // multiple address lookup tables. func TestNewTransactionWithAddressLookupTables_Deterministic(t *testing.T) { - // Define two lookup tables with different addresses + // Define two lookup tables lookupTable1 := MustPublicKeyFromBase58("8Vaso6eE1pWktDHwy2qQBB1fhjmBgwzhoXQKe1sxtFjn") lookupTable2 := MustPublicKeyFromBase58("FqtwFavD9v99FvoaZrY14bGatCQa9ChsMVphEUNAWHeG") - // Addresses in lookup table 1 + // Addresses unique to lookup table 1 addr1InTable1 := MustPublicKeyFromBase58("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA") addr2InTable1 := MustPublicKeyFromBase58("JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4") - // Addresses in lookup table 2 + // 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}, - lookupTable2: {addr1InTable2, addr2InTable2}, + lookupTable1: {addr1InTable1, addr2InTable1, sharedAddr}, + lookupTable2: {sharedAddr, addr1InTable2, addr2InTable2}, // sharedAddr at different index } - // Create instruction that uses accounts from both lookup tables + // 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}, }, From a3410dc9351d2e2bcec4949fe7329cd06c001f13 Mon Sep 17 00:00:00 2001 From: JDeuce Date: Tue, 16 Dec 2025 23:50:10 -0600 Subject: [PATCH 6/6] whitespace: add back missing newline --- transaction.go | 1 + 1 file changed, 1 insertion(+) diff --git a/transaction.go b/transaction.go index b22ce195..8d45c676 100644 --- a/transaction.go +++ b/transaction.go @@ -446,6 +446,7 @@ func NewTransaction(instructions []Instruction, recentBlockHash Hash, opts ...Tr l := lookupsMap[tablePubKey] lookupsWritableKeys = append(lookupsWritableKeys, l.Writable...) lookupsReadOnlyKeys = append(lookupsReadOnlyKeys, l.Readonly...) + lookups = append(lookups, MessageAddressTableLookup{ AccountKey: tablePubKey, WritableIndexes: l.WritableIndexes,