Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 35 additions & 4 deletions transaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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")
Expand All @@ -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)
}
Expand Down Expand Up @@ -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...)

Expand Down
71 changes: 71 additions & 0 deletions transaction_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}