diff --git a/address_table_map.go b/address_table_map.go new file mode 100644 index 00000000..b0211b72 --- /dev/null +++ b/address_table_map.go @@ -0,0 +1,39 @@ +package solana + +import orderedmap "github.com/wk8/go-ordered-map/v2" + +func newAddressTableMap(capacity int) *orderedmap.OrderedMap[PublicKey, PublicKeySlice] { + return orderedmap.New[PublicKey, PublicKeySlice](orderedmap.WithCapacity[PublicKey, PublicKeySlice](capacity)) +} + +func addressTableMapFromMap(tables map[PublicKey]PublicKeySlice) *orderedmap.OrderedMap[PublicKey, PublicKeySlice] { + om := newAddressTableMap(len(tables)) + for k, v := range tables { + om.Set(k, v) + } + return om +} + +func addressTableMapFromSlice(tables []AddressTableEntry) *orderedmap.OrderedMap[PublicKey, PublicKeySlice] { + om := newAddressTableMap(len(tables)) + for _, entry := range tables { + om.Set(entry.TableKey, entry.Addresses) + } + return om +} + +func addressTableMapToMap(om *orderedmap.OrderedMap[PublicKey, PublicKeySlice]) map[PublicKey]PublicKeySlice { + out := make(map[PublicKey]PublicKeySlice, om.Len()) + for pair := om.Oldest(); pair != nil; pair = pair.Next() { + out[pair.Key] = pair.Value + } + return out +} + +func addressTableMapToSlice(om *orderedmap.OrderedMap[PublicKey, PublicKeySlice]) []AddressTableEntry { + out := make([]AddressTableEntry, 0, om.Len()) + for pair := om.Oldest(); pair != nil; pair = pair.Next() { + out = append(out, AddressTableEntry{TableKey: pair.Key, Addresses: pair.Value}) + } + return out +} diff --git a/address_table_map_test.go b/address_table_map_test.go new file mode 100644 index 00000000..a956255e --- /dev/null +++ b/address_table_map_test.go @@ -0,0 +1,115 @@ +package solana + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ( + testTableKey1 = MustPublicKeyFromBase58("8Vaso6eE1pWktDHwy2qQBB1fhjmBgwzhoXQKe1sxtFjn") + testTableKey2 = MustPublicKeyFromBase58("FqtwFavD9v99FvoaZrY14bGatCQa9ChsMVphEUNAWHeG") + testAddr1 = MustPublicKeyFromBase58("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA") + testAddr2 = MustPublicKeyFromBase58("JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4") + testAddr3 = MustPublicKeyFromBase58("So11111111111111111111111111111111111111112") +) + +func TestAddressTableMapFromMap_RoundTrip(t *testing.T) { + tables := map[PublicKey]PublicKeySlice{ + testTableKey1: {testAddr1, testAddr2}, + testTableKey2: {testAddr3}, + } + + om := addressTableMapFromMap(tables) + require.Equal(t, 2, om.Len()) + + result := addressTableMapToMap(om) + assert.Equal(t, tables, result) +} + +func TestAddressTableMapFromMap_Empty(t *testing.T) { + om := addressTableMapFromMap(map[PublicKey]PublicKeySlice{}) + assert.Equal(t, 0, om.Len()) + assert.Equal(t, map[PublicKey]PublicKeySlice{}, addressTableMapToMap(om)) +} + +func TestAddressTableMapFromSlice_PreservesOrder(t *testing.T) { + entries := []AddressTableEntry{ + {TableKey: testTableKey1, Addresses: PublicKeySlice{testAddr1, testAddr2}}, + {TableKey: testTableKey2, Addresses: PublicKeySlice{testAddr3}}, + } + + om := addressTableMapFromSlice(entries) + require.Equal(t, 2, om.Len()) + + // Verify insertion order is preserved. + result := addressTableMapToSlice(om) + require.Len(t, result, 2) + assert.Equal(t, testTableKey1, result[0].TableKey) + assert.Equal(t, PublicKeySlice{testAddr1, testAddr2}, result[0].Addresses) + assert.Equal(t, testTableKey2, result[1].TableKey) + assert.Equal(t, PublicKeySlice{testAddr3}, result[1].Addresses) +} + +func TestAddressTableMapFromSlice_Empty(t *testing.T) { + om := addressTableMapFromSlice([]AddressTableEntry{}) + assert.Equal(t, 0, om.Len()) + assert.Equal(t, []AddressTableEntry{}, addressTableMapToSlice(om)) +} + +func TestAddressTableMapToSlice_RoundTrip(t *testing.T) { + entries := []AddressTableEntry{ + {TableKey: testTableKey2, Addresses: PublicKeySlice{testAddr3}}, + {TableKey: testTableKey1, Addresses: PublicKeySlice{testAddr1, testAddr2}}, + } + + result := addressTableMapToSlice(addressTableMapFromSlice(entries)) + assert.Equal(t, entries, result) +} + +func TestAddressTableMapToMap_NilSafe(t *testing.T) { + // nil ordered map should return empty plain map without panicking. + assert.NotPanics(t, func() { + result := addressTableMapToMap(nil) + assert.Equal(t, map[PublicKey]PublicKeySlice{}, result) + }) +} + +func TestAddressTableMapToSlice_NilSafe(t *testing.T) { + assert.NotPanics(t, func() { + result := addressTableMapToSlice(nil) + assert.Equal(t, []AddressTableEntry{}, result) + }) +} + +// TestAddressTableMapFromSlice_OrderDeterminesTablePriority verifies that when +// the same address appears in two tables, the first entry in the slice wins. +func TestAddressTableMapFromSlice_OrderDeterminesTablePriority(t *testing.T) { + sharedAddr := testAddr1 + + entries := []AddressTableEntry{ + {TableKey: testTableKey1, Addresses: PublicKeySlice{sharedAddr, testAddr2}}, + {TableKey: testTableKey2, Addresses: PublicKeySlice{sharedAddr, testAddr3}}, + } + + om := addressTableMapFromSlice(entries) + + // Build the same addressLookupKeysMap that NewTransaction builds to confirm + // table1 wins for the shared address. + addressLookupKeysMap := make(map[PublicKey]addressTablePubkeyWithIndex) + for pair := om.Oldest(); pair != nil; pair = pair.Next() { + for i, addr := range pair.Value { + if _, exists := addressLookupKeysMap[addr]; !exists { + addressLookupKeysMap[addr] = addressTablePubkeyWithIndex{ + addressTable: pair.Key, + index: uint8(i), + } + } + } + } + + entry := addressLookupKeysMap[sharedAddr] + assert.Equal(t, testTableKey1, entry.addressTable, "first slice entry should win for shared address") + assert.Equal(t, uint8(0), entry.index) +} diff --git a/go.mod b/go.mod index 44b4c460..cca04e7f 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( require ( cloud.google.com/go v0.56.0 // indirect github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/blendle/zapdriver v1.3.1 // indirect github.com/daaku/go.zipexe v1.0.0 // indirect github.com/fsnotify/fsnotify v1.4.7 // indirect @@ -21,6 +22,7 @@ require ( github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/magiconair/properties v1.8.1 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.4 // indirect github.com/mattn/go-isatty v0.0.11 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect @@ -33,6 +35,7 @@ require ( github.com/spf13/cast v1.3.0 // indirect github.com/spf13/jwalterweatherman v1.0.0 // indirect github.com/subosito/gotenv v1.2.0 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect go.uber.org/atomic v1.7.0 // indirect go.uber.org/multierr v1.6.0 // indirect golang.org/x/net v0.0.0-20220722155237-a158d28d115b // indirect @@ -70,7 +73,7 @@ require ( github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.7.1 github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 - github.com/stretchr/testify v1.7.0 + github.com/stretchr/testify v1.8.1 go.mongodb.org/mongo-driver v1.12.2 go.opencensus.io v0.22.5 // indirect go.uber.org/ratelimit v0.2.0 diff --git a/go.sum b/go.sum index d26f0dbc..6fe232b1 100644 --- a/go.sum +++ b/go.sum @@ -42,6 +42,8 @@ github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129/go.mod h1:rFgp github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= @@ -178,6 +180,7 @@ github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NH github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= @@ -203,6 +206,8 @@ github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczG github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4= github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= @@ -299,12 +304,18 @@ github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 h1:RN5mrigyi github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091/go.mod h1:VlduQ80JcGJSargkRU4Sg9Xo63wZD/l8A5NC/Uo1/uU= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/test-go/testify v1.1.4 h1:Tf9lntrKUMHiXQ07qBScBTSA0dhYQlu83hswqelv1iE= @@ -312,6 +323,8 @@ github.com/test-go/testify v1.1.4/go.mod h1:rH7cfJo/47vWGdi4GPj16x3/t1xGOj2YxzmN github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= diff --git a/message.go b/message.go index f033dc15..bcfcba9e 100644 --- a/message.go +++ b/message.go @@ -23,6 +23,7 @@ import ( bin "github.com/gagliardetto/binary" "github.com/gagliardetto/treeout" + orderedmap "github.com/wk8/go-ordered-map/v2" "github.com/gagliardetto/solana-go/text" ) @@ -110,7 +111,7 @@ type Message struct { // The actual tables that contain the list of account pubkeys. // NOTE: you need to fetch these from the chain, and then call `SetAddressTables` // before you use this transaction -- otherwise, you will get a panic. - addressTables map[PublicKey]PublicKeySlice + addressTables *orderedmap.OrderedMap[PublicKey, PublicKeySlice] resolved bool // if true, the lookups have been resolved, and the `AccountKeys` slice contains all the accounts (static + dynamic). } @@ -119,6 +120,26 @@ type Message struct { // Use `mx.GetAddressTableLookups().GetTableIDs()` to get the list of all address table IDs. // NOTE: you can call this once. func (mx *Message) SetAddressTables(tables map[PublicKey]PublicKeySlice) error { + if mx.addressTables != nil { + return fmt.Errorf("address tables already set") + } + mx.addressTables = addressTableMapFromMap(tables) + return nil +} + +// SetAddressTablesSlice sets the actual address tables from a slice, preserving the +// provided order. Use this when insertion order matters, e.g. to control which table +// takes priority when an address appears in multiple tables. +// NOTE: you can call this once. +func (mx *Message) SetAddressTablesSlice(tables []AddressTableEntry) error { + if mx.addressTables != nil { + return fmt.Errorf("address tables already set") + } + mx.addressTables = addressTableMapFromSlice(tables) + return nil +} + +func (mx *Message) setAddressTablesOrdered(tables *orderedmap.OrderedMap[PublicKey, PublicKeySlice]) error { if mx.addressTables != nil { return fmt.Errorf("address tables already set") } @@ -127,9 +148,15 @@ func (mx *Message) SetAddressTables(tables map[PublicKey]PublicKeySlice) error { } // GetAddressTables returns the actual address tables used by this message. -// NOTE: you must have called `SetAddressTable` before being able to use this method. +// NOTE: you must have called `SetAddressTables` before being able to use this method. func (mx *Message) GetAddressTables() map[PublicKey]PublicKeySlice { - return mx.addressTables + return addressTableMapToMap(mx.addressTables) +} + +// GetAddressTablesSlice returns the address tables as a slice in their insertion order. +// NOTE: you must have called `SetAddressTables` before being able to use this method. +func (mx *Message) GetAddressTablesSlice() []AddressTableEntry { + return addressTableMapToSlice(mx.addressTables) } var _ bin.EncoderDecoder = &Message{} @@ -421,7 +448,7 @@ func (mx Message) GetAddressTableLookupAccounts() (PublicKeySlice, error) { var readonly PublicKeySlice for _, lookup := range mx.AddressTableLookups { - table, ok := mx.addressTables[lookup.AccountKey] + table, ok := mx.addressTables.Get(lookup.AccountKey) if !ok { return writable, fmt.Errorf("address table lookup not found for account: %s", lookup.AccountKey) } @@ -657,7 +684,7 @@ func (m Message) checkPreconditions() error { // and there are > 0 lookups, // but the address table is empty, // then we can't build the account meta list: - if m.IsVersioned() && m.AddressTableLookups.NumLookups() > 0 && (m.addressTables == nil || len(m.addressTables) == 0) { + if m.IsVersioned() && m.AddressTableLookups.NumLookups() > 0 && m.addressTables.Len() == 0 { return fmt.Errorf("cannot build account meta list without address tables") } diff --git a/transaction.go b/transaction.go index e09699e7..c92db15c 100644 --- a/transaction.go +++ b/transaction.go @@ -27,6 +27,7 @@ import ( bin "github.com/gagliardetto/binary" "github.com/gagliardetto/treeout" "github.com/mr-tron/base58" + orderedmap "github.com/wk8/go-ordered-map/v2" "go.uber.org/zap" "github.com/gagliardetto/solana-go/text" @@ -164,7 +165,7 @@ type TransactionOption interface { type transactionOptions struct { payer PublicKey - addressTables map[PublicKey]PublicKeySlice // [tablePubkey]addresses + addressTables *orderedmap.OrderedMap[PublicKey, PublicKeySlice] // [tablePubkey]addresses, sorted for determinism } type transactionOptionFunc func(opts *transactionOptions) @@ -178,7 +179,23 @@ func TransactionPayer(payer PublicKey) TransactionOption { } func TransactionAddressTables(tables map[PublicKey]PublicKeySlice) TransactionOption { - return transactionOptionFunc(func(opts *transactionOptions) { opts.addressTables = tables }) + return transactionOptionFunc(func(opts *transactionOptions) { opts.addressTables = addressTableMapFromMap(tables) }) +} + +// AddressTableEntry is a single address lookup table entry used with +// TransactionAddressTablesOrdered. +type AddressTableEntry struct { + TableKey PublicKey + Addresses PublicKeySlice +} + +// TransactionAddressTablesSlice sets address lookup tables in the exact order +// provided, giving the caller full control over serialization order and which +// table takes priority when an address appears in multiple tables. +func TransactionAddressTablesSlice(tables []AddressTableEntry) TransactionOption { + return transactionOptionFunc(func(opts *transactionOptions) { + opts.addressTables = addressTableMapFromSlice(tables) + }) } var debugNewTransaction = false @@ -259,7 +276,8 @@ 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 { + for pair := options.addressTables.Oldest(); pair != nil; pair = pair.Next() { + addressTablePubKey, addressTable := pair.Key, pair.Value if len(addressTable) > 256 { return nil, fmt.Errorf("max lookup table index exceeded for %s table", addressTablePubKey) } @@ -412,7 +430,15 @@ func NewTransaction(instructions []Instruction, recentBlockHash Hash, opts ...Tr if len(lookupsMap) > 0 { lookups := make([]MessageAddressTableLookup, 0, len(lookupsMap)) - for tablePubKey, l := range lookupsMap { + // Iterate options.addressTables (not lookupsMap) so that both + // shared-address priority and final serialization order are controlled + // by the same caller-specified source. + for pair := options.addressTables.Oldest(); pair != nil; pair = pair.Next() { + tablePubKey := pair.Key + l, ok := lookupsMap[tablePubKey] + if !ok { + continue // table provided but no accounts used from it + } lookupsWritableKeys = append(lookupsWritableKeys, l.Writable...) lookupsReadOnlyKeys = append(lookupsReadOnlyKeys, l.Readonly...) @@ -424,7 +450,7 @@ func NewTransaction(instructions []Instruction, recentBlockHash Hash, opts ...Tr } // prevent error created in ResolveLookups - err := message.SetAddressTables(options.addressTables) + err := message.setAddressTablesOrdered(options.addressTables) if err != nil { return nil, fmt.Errorf("SetAddressTables: %s", err) } diff --git a/transaction_test.go b/transaction_test.go index e1c19fe1..71a538ff 100644 --- a/transaction_test.go +++ b/transaction_test.go @@ -399,3 +399,80 @@ func BenchmarkTransactionVerifySignatures(b *testing.B) { tx.VerifySignatures() } } + +// TestNewTransactionWithAddressLookupTables_Deterministic verifies two ordering +// guarantees of TransactionAddressTablesSlice: +// 1. Byte-identical serialization across repeated builds (determinism). +// 2. The slice position controls both shared-address priority AND the order of +// MessageAddressTableLookups in the serialized message. +func TestNewTransactionWithAddressLookupTables_Deterministic(t *testing.T) { + lookupTable1 := MustPublicKeyFromBase58("8Vaso6eE1pWktDHwy2qQBB1fhjmBgwzhoXQKe1sxtFjn") + lookupTable2 := MustPublicKeyFromBase58("FqtwFavD9v99FvoaZrY14bGatCQa9ChsMVphEUNAWHeG") + + addr1InTable1 := MustPublicKeyFromBase58("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA") + addr2InTable1 := MustPublicKeyFromBase58("JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4") + + // addr1InTable2 sorts before anything in table1 in the account priority + // ordering, so without the fix table2 would appear first in the serialized + // lookups even though table1 is listed first in the slice. + addr1InTable2 := MustPublicKeyFromBase58("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL") + addr2InTable2 := MustPublicKeyFromBase58("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb") + + // sharedAddr exists in both tables at different indices; table1 wins because + // it is listed first in the AddressTableEntry slice. + sharedAddr := MustPublicKeyFromBase58("So11111111111111111111111111111111111111112") + + feePayer := MustPublicKeyFromBase58("7KxrPswMgoVFwC1K7KmudEW9KzmVUCdBnLBMaSAjYQGp") + programID := MustPublicKeyFromBase58("11111111111111111111111111111111") + + addressTables := []AddressTableEntry{ + {TableKey: lookupTable1, Addresses: PublicKeySlice{addr1InTable1, addr2InTable1, sharedAddr}}, + {TableKey: lookupTable2, Addresses: PublicKeySlice{sharedAddr, addr1InTable2, addr2InTable2}}, + } + + 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}, + {PublicKey: addr1InTable2, IsSigner: false, IsWritable: false}, + {PublicKey: addr2InTable2, IsSigner: false, IsWritable: true}, + }, + data: []byte{0x01, 0x02, 0x03}, + programID: programID, + } + + blockhash := MustHashFromBase58("4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZAMdL4VZHirAn") + + var firstSerialized []byte + for i := 0; i < 100; i++ { + tx, err := NewTransaction( + []Instruction{instruction}, + blockhash, + TransactionPayer(feePayer), + TransactionAddressTablesSlice(addressTables), + ) + require.NoError(t, err) + + // Lookup serialization order must match slice order: table1 first. + require.Len(t, tx.Message.AddressTableLookups, 2) + assert.Equal(t, lookupTable1, tx.Message.AddressTableLookups[0].AccountKey, + "table1 must be serialized first (slice position controls lookup order)") + assert.Equal(t, lookupTable2, tx.Message.AddressTableLookups[1].AccountKey) + + // sharedAddr must be assigned to table1 (first wins). + assert.Equal(t, uint8(2), tx.Message.AddressTableLookups[0].WritableIndexes[1], + "sharedAddr index in table1 is 2") + + serialized, err := tx.MarshalBinary() + require.NoError(t, err) + + if i == 0 { + firstSerialized = serialized + } else { + assert.Equal(t, firstSerialized, serialized, + "Transaction serialization must be deterministic (iteration %d)", i) + } + } +} diff --git a/transaction_v0_test.go b/transaction_v0_test.go index 8e6de5c8..8bcce32c 100644 --- a/transaction_v0_test.go +++ b/transaction_v0_test.go @@ -39,7 +39,6 @@ func TestTransactionV0(t *testing.T) { tables, ) require.NoError(t, err) - require.Equal(t, tables, tx.Message.addressTables) require.Equal(t, tables, tx.Message.GetAddressTables()) require.Equal(t, MessageVersionV0, tx.Message.GetVersion())