Skip to content
Merged
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: 39 additions & 0 deletions rpc/util.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package rpc

import "github.com/gagliardetto/solana-go"

// Redefined locally to avoid a cycle with the token2022 sub-package, which
// imports rpc. Canonical copies: token2022.MINT_SIZE / ACCOUNT_SIZE /
// AccountTypeMint.
const (
tokenMintSize = 82
token2022AccountBaseSize = 165
token2022AccountTypeMint = 1
)

// IsTokenMint reports whether acc holds an SPL Token or Token-2022 Mint,
// classifying by byte layout instead of a full borsh decode. Safe on nil
// acc and nil acc.Data.
//
// Token-2022 pads Mint records from 82 to 165 bytes and places a 1-byte
// AccountType discriminator (1 = Mint, 2 = Account) at offset 165 when
// extensions are present.
func IsTokenMint(acc *Account) bool {
if acc == nil {
return false
}
data := acc.Data.GetBinary()
n := len(data)

// Length check first: a 32-byte PublicKey compare costs more than a
// length compare, so reject non-mint shapes before touching Owner.
if n == tokenMintSize {
return acc.Owner == solana.TokenProgramID ||
acc.Owner == solana.Token2022ProgramID
}
if n > token2022AccountBaseSize &&
data[token2022AccountBaseSize] == token2022AccountTypeMint {
return acc.Owner == solana.Token2022ProgramID
}
return false
}
107 changes: 107 additions & 0 deletions rpc/util_bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package rpc

import (
"bytes"
"runtime"
"testing"

"github.com/gagliardetto/solana-go"
)

var isTokenMintBenchSink bool

// isTokenMintPR396 is the classifier as proposed in PR #396. Kept in a test
// file so benchmarks can measure the current implementation against it
// without polluting the public API. Intentionally preserved verbatim —
// including the missing nil-guard on acc — so the comparison reflects the
// PR as submitted.
func isTokenMintPR396(acc *Account) bool {
data := acc.Data.GetBinary()
n := len(data)

switch acc.Owner {
case solana.TokenProgramID:
return n == 82
case solana.Token2022ProgramID:
if n == 82 {
return true //Normal Mint
}
if n <= 165 {
return false //Normal Token Account
}
return data[165] == 1 // Mint Extensions
}

return false
}

// buildMixedAccounts returns a set of account shapes approximating an
// unfiltered getProgramAccounts result: bare mints, extended mints,
// accounts, and non-token junk. Indexing by i prevents the compiler from
// constant-folding benchmark inputs.
func buildMixedAccounts() []*Account {
extMint := append(make([]byte, 165), 1)
extMint = append(extMint, bytes.Repeat([]byte{3, 0, 32, 0}, 1)...)
extAcc := append(make([]byte, 165), 2)
return []*Account{
{Owner: solana.TokenProgramID, Data: DataBytesOrJSONFromBytes(make([]byte, 82))},
{Owner: solana.TokenProgramID, Data: DataBytesOrJSONFromBytes(make([]byte, 165))},
{Owner: solana.Token2022ProgramID, Data: DataBytesOrJSONFromBytes(make([]byte, 82))},
{Owner: solana.Token2022ProgramID, Data: DataBytesOrJSONFromBytes(make([]byte, 165))},
{Owner: solana.Token2022ProgramID, Data: DataBytesOrJSONFromBytes(extMint)},
{Owner: solana.Token2022ProgramID, Data: DataBytesOrJSONFromBytes(extAcc)},
{Owner: solana.SystemProgramID, Data: DataBytesOrJSONFromBytes(make([]byte, 0))},
{Owner: solana.SystemProgramID, Data: DataBytesOrJSONFromBytes(make([]byte, 200))},
}
}

func runIsTokenMintBench(b *testing.B, fn func(*Account) bool, accounts []*Account) {
var acc bool
var i int
for b.Loop() {
acc = acc != fn(accounts[i%len(accounts)])
i++
}
isTokenMintBenchSink = acc
runtime.KeepAlive(&isTokenMintBenchSink)
}

// --- PR #396 version (baseline) ---

func BenchmarkIsTokenMint_PR396_Mixed(b *testing.B) {
runIsTokenMintBench(b, isTokenMintPR396, buildMixedAccounts())
}

func BenchmarkIsTokenMint_PR396_HotMint(b *testing.B) {
runIsTokenMintBench(b, isTokenMintPR396, []*Account{
{Owner: solana.TokenProgramID, Data: DataBytesOrJSONFromBytes(make([]byte, 82))},
{Owner: solana.TokenProgramID, Data: DataBytesOrJSONFromBytes(make([]byte, 82))},
})
}

func BenchmarkIsTokenMint_PR396_WrongOwner(b *testing.B) {
runIsTokenMintBench(b, isTokenMintPR396, []*Account{
{Owner: solana.SystemProgramID, Data: DataBytesOrJSONFromBytes(make([]byte, 200))},
{Owner: solana.SystemProgramID, Data: DataBytesOrJSONFromBytes(make([]byte, 500))},
})
}

// --- Current (length-first) version ---

func BenchmarkIsTokenMint_Current_Mixed(b *testing.B) {
runIsTokenMintBench(b, IsTokenMint, buildMixedAccounts())
}

func BenchmarkIsTokenMint_Current_HotMint(b *testing.B) {
runIsTokenMintBench(b, IsTokenMint, []*Account{
{Owner: solana.TokenProgramID, Data: DataBytesOrJSONFromBytes(make([]byte, 82))},
{Owner: solana.TokenProgramID, Data: DataBytesOrJSONFromBytes(make([]byte, 82))},
})
}

func BenchmarkIsTokenMint_Current_WrongOwner(b *testing.B) {
runIsTokenMintBench(b, IsTokenMint, []*Account{
{Owner: solana.SystemProgramID, Data: DataBytesOrJSONFromBytes(make([]byte, 200))},
{Owner: solana.SystemProgramID, Data: DataBytesOrJSONFromBytes(make([]byte, 500))},
})
}
208 changes: 208 additions & 0 deletions rpc/util_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
package rpc

import (
"bytes"
"testing"

"github.com/gagliardetto/solana-go"
"github.com/stretchr/testify/require"
)

// Layout references for the test data below:
//
// SPL Token (solana-program/token):
// Mint::LEN = 82
// Account::LEN = 165
//
// Token-2022 (solana-program/token-2022):
// Extended records place a 1-byte AccountType discriminator at offset
// Account::LEN (= 165). Mint base (82 bytes) is padded with 83 zeros so
// Mint and Account share the discriminator offset.
//
// AccountType::Uninitialized = 0
// AccountType::Mint = 1
// AccountType::Account = 2
//
// Extensions follow as TLV: [u16 LE type][u16 LE length][value...].
const (
testAccountTypeUninitialized uint8 = 0
testAccountTypeMint uint8 = 1
testAccountTypeAccount uint8 = 2

// Real Token-2022 extension type numbers used in the realistic TLV
// fixtures below.
testExtTypeMintCloseAuthority uint16 = 3
testExtTypeImmutableOwner uint16 = 7
)

func mkAccount(owner solana.PublicKey, data []byte) *Account {
return &Account{
Owner: owner,
Data: DataBytesOrJSONFromBytes(data),
}
}

// mkToken2022ExtensionData builds a synthetic Token-2022 extended account
// record: base (padded to 165 bytes) + 1-byte AccountType discriminator +
// one TLV entry.
func mkToken2022ExtensionData(accountType uint8, extType uint16, extValue []byte) []byte {
data := make([]byte, 0, token2022AccountBaseSize+1+4+len(extValue))
data = append(data, bytes.Repeat([]byte{0}, token2022AccountBaseSize)...)
data = append(data, accountType)
data = append(data,
byte(extType), byte(extType>>8),
byte(len(extValue)), byte(len(extValue)>>8),
)
data = append(data, extValue...)
return data
}

func TestIsTokenMint(t *testing.T) {
// A 32-byte pubkey payload used as an extension value.
pubkeyValue := bytes.Repeat([]byte{0xAB}, 32)

tests := []struct {
name string
acc *Account
want bool
}{
// --- nil / empty handling ---
{
name: "nil account",
acc: nil,
want: false,
},
{
name: "nil Data field",
acc: &Account{Owner: solana.TokenProgramID},
want: false,
},
{
name: "empty data, Token owner",
acc: mkAccount(solana.TokenProgramID, nil),
want: false,
},
{
name: "empty data, Token-2022 owner",
acc: mkAccount(solana.Token2022ProgramID, nil),
want: false,
},

// --- wrong owner ---
{
name: "SystemProgram owner, mint-sized data",
acc: mkAccount(solana.SystemProgramID, make([]byte, 82)),
want: false,
},
{
name: "random owner, mint-sized data",
acc: mkAccount(solana.MustPublicKeyFromBase58("11111111111111111111111111111112"), make([]byte, 82)),
want: false,
},

// --- SPL Token ---
{
name: "SPL Token: exact Mint::LEN = 82",
acc: mkAccount(solana.TokenProgramID, make([]byte, 82)),
want: true,
},
{
name: "SPL Token: 81 bytes (one short of Mint::LEN)",
acc: mkAccount(solana.TokenProgramID, make([]byte, 81)),
want: false,
},
{
name: "SPL Token: 83 bytes (one past Mint::LEN)",
acc: mkAccount(solana.TokenProgramID, make([]byte, 83)),
want: false,
},
{
name: "SPL Token: Account::LEN = 165 (a token account, not a mint)",
acc: mkAccount(solana.TokenProgramID, make([]byte, 165)),
want: false,
},
{
name: "SPL Token: extension-shaped data is not valid for classic Token",
acc: mkAccount(solana.TokenProgramID,
mkToken2022ExtensionData(testAccountTypeMint, testExtTypeMintCloseAuthority, pubkeyValue)),
want: false,
},

// --- Token-2022 bare (no extensions) ---
{
name: "Token-2022: bare Mint (82 bytes)",
acc: mkAccount(solana.Token2022ProgramID, make([]byte, 82)),
want: true,
},
{
name: "Token-2022: bare Account (165 bytes, no discriminator)",
acc: mkAccount(solana.Token2022ProgramID, make([]byte, 165)),
want: false,
},

// --- Token-2022 invalid intermediate sizes ---
{
name: "Token-2022: 83 bytes (between Mint and Account sizes)",
acc: mkAccount(solana.Token2022ProgramID, make([]byte, 83)),
want: false,
},
{
name: "Token-2022: 164 bytes (one short of Account::LEN)",
acc: mkAccount(solana.Token2022ProgramID, make([]byte, 164)),
want: false,
},

// --- Token-2022 extended record discriminator cases ---
{
name: "Token-2022: extended with AccountType=Mint (1) at offset 165",
acc: mkAccount(solana.Token2022ProgramID,
mkToken2022ExtensionData(testAccountTypeMint, testExtTypeMintCloseAuthority, pubkeyValue)),
want: true,
},
{
name: "Token-2022: extended with AccountType=Account (2) at offset 165",
acc: mkAccount(solana.Token2022ProgramID,
mkToken2022ExtensionData(testAccountTypeAccount, testExtTypeImmutableOwner, nil)),
want: false,
},
{
name: "Token-2022: extended with AccountType=Uninitialized (0) at offset 165",
acc: mkAccount(solana.Token2022ProgramID,
mkToken2022ExtensionData(testAccountTypeUninitialized, 0, nil)),
want: false,
},
{
name: "Token-2022: extended with unknown discriminator (3)",
acc: mkAccount(solana.Token2022ProgramID,
mkToken2022ExtensionData(3, 0, nil)),
want: false,
},
{
name: "Token-2022: 166 bytes, discriminator byte = Mint, no TLV payload",
acc: mkAccount(solana.Token2022ProgramID,
append(make([]byte, 165), testAccountTypeMint)),
want: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tt.want, IsTokenMint(tt.acc))
})
}
}

// TestIsTokenMint_RealisticExtendedMintSize anchors the Token-2022 mint-
// with-extensions layout at the same byte length the Token-2022 sub-
// package already exercises in extension_test.go (a MintCloseAuthority
// extension produces a 202-byte record).
func TestIsTokenMint_RealisticExtendedMintSize(t *testing.T) {
data := mkToken2022ExtensionData(
testAccountTypeMint,
testExtTypeMintCloseAuthority,
bytes.Repeat([]byte{1}, 32),
)
require.Equal(t, 202, len(data), "MintCloseAuthority TLV should produce a 202-byte record")
require.True(t, IsTokenMint(mkAccount(solana.Token2022ProgramID, data)))
}
Loading