Skip to content

Commit 4920307

Browse files
feat: is token mint classifier
1 parent 828ac78 commit 4920307

3 files changed

Lines changed: 354 additions & 0 deletions

File tree

rpc/util.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package rpc
2+
3+
import "github.com/gagliardetto/solana-go"
4+
5+
// Redefined locally to avoid a cycle with the token2022 sub-package, which
6+
// imports rpc. Canonical copies: token2022.MINT_SIZE / ACCOUNT_SIZE /
7+
// AccountTypeMint.
8+
const (
9+
tokenMintSize = 82
10+
token2022AccountBaseSize = 165
11+
token2022AccountTypeMint = 1
12+
)
13+
14+
// IsTokenMint reports whether acc holds an SPL Token or Token-2022 Mint,
15+
// classifying by byte layout instead of a full borsh decode. Safe on nil
16+
// acc and nil acc.Data.
17+
//
18+
// Token-2022 pads Mint records from 82 to 165 bytes and places a 1-byte
19+
// AccountType discriminator (1 = Mint, 2 = Account) at offset 165 when
20+
// extensions are present.
21+
func IsTokenMint(acc *Account) bool {
22+
if acc == nil {
23+
return false
24+
}
25+
data := acc.Data.GetBinary()
26+
n := len(data)
27+
28+
// Length check first: a 32-byte PublicKey compare costs more than a
29+
// length compare, so reject non-mint shapes before touching Owner.
30+
if n == tokenMintSize {
31+
return acc.Owner == solana.TokenProgramID ||
32+
acc.Owner == solana.Token2022ProgramID
33+
}
34+
if n > token2022AccountBaseSize &&
35+
data[token2022AccountBaseSize] == token2022AccountTypeMint {
36+
return acc.Owner == solana.Token2022ProgramID
37+
}
38+
return false
39+
}

rpc/util_bench_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package rpc
2+
3+
import (
4+
"bytes"
5+
"runtime"
6+
"testing"
7+
8+
"github.com/gagliardetto/solana-go"
9+
)
10+
11+
var isTokenMintBenchSink bool
12+
13+
// isTokenMintPR396 is the classifier as proposed in PR #396. Kept in a test
14+
// file so benchmarks can measure the current implementation against it
15+
// without polluting the public API. Intentionally preserved verbatim —
16+
// including the missing nil-guard on acc — so the comparison reflects the
17+
// PR as submitted.
18+
func isTokenMintPR396(acc *Account) bool {
19+
data := acc.Data.GetBinary()
20+
n := len(data)
21+
22+
switch acc.Owner {
23+
case solana.TokenProgramID:
24+
return n == 82
25+
case solana.Token2022ProgramID:
26+
if n == 82 {
27+
return true //Normal Mint
28+
}
29+
if n <= 165 {
30+
return false //Normal Token Account
31+
}
32+
return data[165] == 1 // Mint Extensions
33+
}
34+
35+
return false
36+
}
37+
38+
// buildMixedAccounts returns a set of account shapes approximating an
39+
// unfiltered getProgramAccounts result: bare mints, extended mints,
40+
// accounts, and non-token junk. Indexing by i prevents the compiler from
41+
// constant-folding benchmark inputs.
42+
func buildMixedAccounts() []*Account {
43+
extMint := append(make([]byte, 165), 1)
44+
extMint = append(extMint, bytes.Repeat([]byte{3, 0, 32, 0}, 1)...)
45+
extAcc := append(make([]byte, 165), 2)
46+
return []*Account{
47+
{Owner: solana.TokenProgramID, Data: DataBytesOrJSONFromBytes(make([]byte, 82))},
48+
{Owner: solana.TokenProgramID, Data: DataBytesOrJSONFromBytes(make([]byte, 165))},
49+
{Owner: solana.Token2022ProgramID, Data: DataBytesOrJSONFromBytes(make([]byte, 82))},
50+
{Owner: solana.Token2022ProgramID, Data: DataBytesOrJSONFromBytes(make([]byte, 165))},
51+
{Owner: solana.Token2022ProgramID, Data: DataBytesOrJSONFromBytes(extMint)},
52+
{Owner: solana.Token2022ProgramID, Data: DataBytesOrJSONFromBytes(extAcc)},
53+
{Owner: solana.SystemProgramID, Data: DataBytesOrJSONFromBytes(make([]byte, 0))},
54+
{Owner: solana.SystemProgramID, Data: DataBytesOrJSONFromBytes(make([]byte, 200))},
55+
}
56+
}
57+
58+
func runIsTokenMintBench(b *testing.B, fn func(*Account) bool, accounts []*Account) {
59+
var acc bool
60+
var i int
61+
for b.Loop() {
62+
acc = acc != fn(accounts[i%len(accounts)])
63+
i++
64+
}
65+
isTokenMintBenchSink = acc
66+
runtime.KeepAlive(&isTokenMintBenchSink)
67+
}
68+
69+
// --- PR #396 version (baseline) ---
70+
71+
func BenchmarkIsTokenMint_PR396_Mixed(b *testing.B) {
72+
runIsTokenMintBench(b, isTokenMintPR396, buildMixedAccounts())
73+
}
74+
75+
func BenchmarkIsTokenMint_PR396_HotMint(b *testing.B) {
76+
runIsTokenMintBench(b, isTokenMintPR396, []*Account{
77+
{Owner: solana.TokenProgramID, Data: DataBytesOrJSONFromBytes(make([]byte, 82))},
78+
{Owner: solana.TokenProgramID, Data: DataBytesOrJSONFromBytes(make([]byte, 82))},
79+
})
80+
}
81+
82+
func BenchmarkIsTokenMint_PR396_WrongOwner(b *testing.B) {
83+
runIsTokenMintBench(b, isTokenMintPR396, []*Account{
84+
{Owner: solana.SystemProgramID, Data: DataBytesOrJSONFromBytes(make([]byte, 200))},
85+
{Owner: solana.SystemProgramID, Data: DataBytesOrJSONFromBytes(make([]byte, 500))},
86+
})
87+
}
88+
89+
// --- Current (length-first) version ---
90+
91+
func BenchmarkIsTokenMint_Current_Mixed(b *testing.B) {
92+
runIsTokenMintBench(b, IsTokenMint, buildMixedAccounts())
93+
}
94+
95+
func BenchmarkIsTokenMint_Current_HotMint(b *testing.B) {
96+
runIsTokenMintBench(b, IsTokenMint, []*Account{
97+
{Owner: solana.TokenProgramID, Data: DataBytesOrJSONFromBytes(make([]byte, 82))},
98+
{Owner: solana.TokenProgramID, Data: DataBytesOrJSONFromBytes(make([]byte, 82))},
99+
})
100+
}
101+
102+
func BenchmarkIsTokenMint_Current_WrongOwner(b *testing.B) {
103+
runIsTokenMintBench(b, IsTokenMint, []*Account{
104+
{Owner: solana.SystemProgramID, Data: DataBytesOrJSONFromBytes(make([]byte, 200))},
105+
{Owner: solana.SystemProgramID, Data: DataBytesOrJSONFromBytes(make([]byte, 500))},
106+
})
107+
}

rpc/util_test.go

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
package rpc
2+
3+
import (
4+
"bytes"
5+
"testing"
6+
7+
"github.com/gagliardetto/solana-go"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
// Layout references for the test data below:
12+
//
13+
// SPL Token (solana-program/token):
14+
// Mint::LEN = 82
15+
// Account::LEN = 165
16+
//
17+
// Token-2022 (solana-program/token-2022):
18+
// Extended records place a 1-byte AccountType discriminator at offset
19+
// Account::LEN (= 165). Mint base (82 bytes) is padded with 83 zeros so
20+
// Mint and Account share the discriminator offset.
21+
//
22+
// AccountType::Uninitialized = 0
23+
// AccountType::Mint = 1
24+
// AccountType::Account = 2
25+
//
26+
// Extensions follow as TLV: [u16 LE type][u16 LE length][value...].
27+
const (
28+
testAccountTypeUninitialized uint8 = 0
29+
testAccountTypeMint uint8 = 1
30+
testAccountTypeAccount uint8 = 2
31+
32+
// Real Token-2022 extension type numbers used in the realistic TLV
33+
// fixtures below.
34+
testExtTypeMintCloseAuthority uint16 = 3
35+
testExtTypeImmutableOwner uint16 = 7
36+
)
37+
38+
func mkAccount(owner solana.PublicKey, data []byte) *Account {
39+
return &Account{
40+
Owner: owner,
41+
Data: DataBytesOrJSONFromBytes(data),
42+
}
43+
}
44+
45+
// mkToken2022ExtensionData builds a synthetic Token-2022 extended account
46+
// record: base (padded to 165 bytes) + 1-byte AccountType discriminator +
47+
// one TLV entry.
48+
func mkToken2022ExtensionData(accountType uint8, extType uint16, extValue []byte) []byte {
49+
data := make([]byte, 0, token2022AccountBaseSize+1+4+len(extValue))
50+
data = append(data, bytes.Repeat([]byte{0}, token2022AccountBaseSize)...)
51+
data = append(data, accountType)
52+
data = append(data,
53+
byte(extType), byte(extType>>8),
54+
byte(len(extValue)), byte(len(extValue)>>8),
55+
)
56+
data = append(data, extValue...)
57+
return data
58+
}
59+
60+
func TestIsTokenMint(t *testing.T) {
61+
// A 32-byte pubkey payload used as an extension value.
62+
pubkeyValue := bytes.Repeat([]byte{0xAB}, 32)
63+
64+
tests := []struct {
65+
name string
66+
acc *Account
67+
want bool
68+
}{
69+
// --- nil / empty handling ---
70+
{
71+
name: "nil account",
72+
acc: nil,
73+
want: false,
74+
},
75+
{
76+
name: "nil Data field",
77+
acc: &Account{Owner: solana.TokenProgramID},
78+
want: false,
79+
},
80+
{
81+
name: "empty data, Token owner",
82+
acc: mkAccount(solana.TokenProgramID, nil),
83+
want: false,
84+
},
85+
{
86+
name: "empty data, Token-2022 owner",
87+
acc: mkAccount(solana.Token2022ProgramID, nil),
88+
want: false,
89+
},
90+
91+
// --- wrong owner ---
92+
{
93+
name: "SystemProgram owner, mint-sized data",
94+
acc: mkAccount(solana.SystemProgramID, make([]byte, 82)),
95+
want: false,
96+
},
97+
{
98+
name: "random owner, mint-sized data",
99+
acc: mkAccount(solana.MustPublicKeyFromBase58("11111111111111111111111111111112"), make([]byte, 82)),
100+
want: false,
101+
},
102+
103+
// --- SPL Token ---
104+
{
105+
name: "SPL Token: exact Mint::LEN = 82",
106+
acc: mkAccount(solana.TokenProgramID, make([]byte, 82)),
107+
want: true,
108+
},
109+
{
110+
name: "SPL Token: 81 bytes (one short of Mint::LEN)",
111+
acc: mkAccount(solana.TokenProgramID, make([]byte, 81)),
112+
want: false,
113+
},
114+
{
115+
name: "SPL Token: 83 bytes (one past Mint::LEN)",
116+
acc: mkAccount(solana.TokenProgramID, make([]byte, 83)),
117+
want: false,
118+
},
119+
{
120+
name: "SPL Token: Account::LEN = 165 (a token account, not a mint)",
121+
acc: mkAccount(solana.TokenProgramID, make([]byte, 165)),
122+
want: false,
123+
},
124+
{
125+
name: "SPL Token: extension-shaped data is not valid for classic Token",
126+
acc: mkAccount(solana.TokenProgramID,
127+
mkToken2022ExtensionData(testAccountTypeMint, testExtTypeMintCloseAuthority, pubkeyValue)),
128+
want: false,
129+
},
130+
131+
// --- Token-2022 bare (no extensions) ---
132+
{
133+
name: "Token-2022: bare Mint (82 bytes)",
134+
acc: mkAccount(solana.Token2022ProgramID, make([]byte, 82)),
135+
want: true,
136+
},
137+
{
138+
name: "Token-2022: bare Account (165 bytes, no discriminator)",
139+
acc: mkAccount(solana.Token2022ProgramID, make([]byte, 165)),
140+
want: false,
141+
},
142+
143+
// --- Token-2022 invalid intermediate sizes ---
144+
{
145+
name: "Token-2022: 83 bytes (between Mint and Account sizes)",
146+
acc: mkAccount(solana.Token2022ProgramID, make([]byte, 83)),
147+
want: false,
148+
},
149+
{
150+
name: "Token-2022: 164 bytes (one short of Account::LEN)",
151+
acc: mkAccount(solana.Token2022ProgramID, make([]byte, 164)),
152+
want: false,
153+
},
154+
155+
// --- Token-2022 extended record discriminator cases ---
156+
{
157+
name: "Token-2022: extended with AccountType=Mint (1) at offset 165",
158+
acc: mkAccount(solana.Token2022ProgramID,
159+
mkToken2022ExtensionData(testAccountTypeMint, testExtTypeMintCloseAuthority, pubkeyValue)),
160+
want: true,
161+
},
162+
{
163+
name: "Token-2022: extended with AccountType=Account (2) at offset 165",
164+
acc: mkAccount(solana.Token2022ProgramID,
165+
mkToken2022ExtensionData(testAccountTypeAccount, testExtTypeImmutableOwner, nil)),
166+
want: false,
167+
},
168+
{
169+
name: "Token-2022: extended with AccountType=Uninitialized (0) at offset 165",
170+
acc: mkAccount(solana.Token2022ProgramID,
171+
mkToken2022ExtensionData(testAccountTypeUninitialized, 0, nil)),
172+
want: false,
173+
},
174+
{
175+
name: "Token-2022: extended with unknown discriminator (3)",
176+
acc: mkAccount(solana.Token2022ProgramID,
177+
mkToken2022ExtensionData(3, 0, nil)),
178+
want: false,
179+
},
180+
{
181+
name: "Token-2022: 166 bytes, discriminator byte = Mint, no TLV payload",
182+
acc: mkAccount(solana.Token2022ProgramID,
183+
append(make([]byte, 165), testAccountTypeMint)),
184+
want: true,
185+
},
186+
}
187+
188+
for _, tt := range tests {
189+
t.Run(tt.name, func(t *testing.T) {
190+
t.Parallel()
191+
require.Equal(t, tt.want, IsTokenMint(tt.acc))
192+
})
193+
}
194+
}
195+
196+
// TestIsTokenMint_RealisticExtendedMintSize anchors the Token-2022 mint-
197+
// with-extensions layout at the same byte length the Token-2022 sub-
198+
// package already exercises in extension_test.go (a MintCloseAuthority
199+
// extension produces a 202-byte record).
200+
func TestIsTokenMint_RealisticExtendedMintSize(t *testing.T) {
201+
data := mkToken2022ExtensionData(
202+
testAccountTypeMint,
203+
testExtTypeMintCloseAuthority,
204+
bytes.Repeat([]byte{1}, 32),
205+
)
206+
require.Equal(t, 202, len(data), "MintCloseAuthority TLV should produce a 202-byte record")
207+
require.True(t, IsTokenMint(mkAccount(solana.Token2022ProgramID, data)))
208+
}

0 commit comments

Comments
 (0)