From 84a19c9c40fd1ed13a57acd8856720aeecbe4e19 Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Mon, 1 Dec 2025 13:46:48 -0800 Subject: [PATCH 1/6] Create script for reclaiming rent --- cmd/reclaim_rent/main.go | 255 ++++++++++++++++++ go.mod | 6 +- go.sum | 14 +- solana/spl/programs/claimable_tokens/Close.go | 117 ++++++++ .../claimable_tokens/CreateTokenAccount.go | 2 +- .../spl/programs/claimable_tokens/Transfer.go | 2 +- .../spl/programs/claimable_tokens/accounts.go | 4 +- .../programs/claimable_tokens/instruction.go | 2 + 8 files changed, 387 insertions(+), 15 deletions(-) create mode 100644 cmd/reclaim_rent/main.go create mode 100644 solana/spl/programs/claimable_tokens/Close.go diff --git a/cmd/reclaim_rent/main.go b/cmd/reclaim_rent/main.go new file mode 100644 index 00000000..ea0d9e93 --- /dev/null +++ b/cmd/reclaim_rent/main.go @@ -0,0 +1,255 @@ +package main + +import ( + "context" + "fmt" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/spf13/cobra" + + "api.audius.co/solana/spl/programs/claimable_tokens" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + "github.com/jackc/pgx/v5/pgxpool" +) + +var reclaimRentCmd = &cobra.Command{ + Use: "reclaim_rent ", + Short: "Reclaim rent from empty token accounts for a given mint", + Args: cobra.ExactArgs(1), + RunE: reclaimRent, +} + +func main() { + err := reclaimRentCmd.Execute() + if err != nil { + fmt.Println("Error executing command:", err) + } +} + +func init() { + reclaimRentCmd.Flags().StringP("rpc", "r", "https://api.mainnet-beta.solana.com", "The Solana RPC endpoint to use") + reclaimRentCmd.Flags().StringP("database", "c", "postgres://postgres:postgres@localhost:5432/discovery_provider_1?sslmode=disable", "Database connection string") + reclaimRentCmd.Flags().StringP("keypair", "k", "~/.config/solana/id.json", "The wallet to use as fee payer for transactions") + reclaimRentCmd.Flags().StringP("destination", "d", "", "The recipient of reclaimed rent (defaults to fee payer)") +} + +func reclaimRent(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + rpcEndpoint, err := cmd.Flags().GetString("rpc") + if err != nil { + return fmt.Errorf("failed to get rpc flag: %w", err) + } + rpcClient := rpc.New(rpcEndpoint) + + databaseURL, err := cmd.Flags().GetString("database") + if err != nil { + return fmt.Errorf("failed to get database flag: %w", err) + } + + config, err := pgxpool.ParseConfig(databaseURL) + if err != nil { + return fmt.Errorf("failed to parse database URL: %w", err) + } + pool, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + return fmt.Errorf("failed to create database pool: %w", err) + } + defer pool.Close() + + feePayerFlag, err := cmd.Flags().GetString("keypair") + if err != nil { + return fmt.Errorf("failed to get keypair flag: %w", err) + } + keypair, err := solana.PrivateKeyFromSolanaKeygenFile(feePayerFlag) + if err != nil { + return fmt.Errorf("failed to load keypair: %w", err) + } + + destinationFlag, err := cmd.Flags().GetString("destination") + if err != nil { + return fmt.Errorf("failed to get destination flag: %w", err) + } + var destination solana.PublicKey + if destinationFlag == "" { + destination = keypair.PublicKey() + } else { + destination = solana.MustPublicKeyFromBase58(destinationFlag) + } + + mint := solana.MustPublicKeyFromBase58(args[0]) + + fmt.Println("Reclaiming rent for mint:", args[0]) + + authority, _, err := claimable_tokens.DeriveAuthority(mint) + if err != nil { + return fmt.Errorf("failed to derive authority: %w", err) + } + + for { + res, err := getEmptyTokenAccounts(ctx, rpcClient, mint, authority) + if err != nil { + return fmt.Errorf("failed to get token accounts: %w", err) + } + + if res.PaginationKey == nil { + fmt.Println("No more empty token accounts to process.") + break + } + + fmt.Printf("Found %d empty token accounts for mint %s owned by authority %s\n", len(res.Accounts), mint.String(), authority.String()) + + i := 0 + batchSize := 15 + + for { + batch := make([]solana.PublicKey, 0, batchSize) + for j := i; j < i+batchSize && j < len(res.Accounts); j++ { + batch = append(batch, res.Accounts[j].Pubkey) + } + if len(batch) == 0 { + break + } + txSig, err := processBatch(ctx, pool, rpcClient, batch, authority, destination, keypair) + if err != nil { + return fmt.Errorf("failed to process batch: %w", err) + } + if txSig != nil { + fmt.Printf("Submitted transaction %s to reclaim rent for %d accounts\n", txSig.String(), len(batch)) + } + time.Sleep(time.Second / 500 * 2) // Max 500 req/s (2 req per batch) to avoid rate limiting + + i += batchSize + + // TODO: do more than one batch + fmt.Println("Processed one batch, exiting for now.") + return nil + } + } + return nil +} + +func processBatch(ctx context.Context, pool *pgxpool.Pool, rpcClient *rpc.Client, batch []solana.PublicKey, authority solana.PublicKey, destination solana.PublicKey, keypair solana.PrivateKey) (*solana.Signature, error) { + ethAddresses, err := getEthAddressesFromAccounts(ctx, pool, batch) + if err != nil { + return nil, fmt.Errorf("failed to get ETH addresses: %w", err) + } + + instructions := make([]solana.Instruction, 0, len(batch)) + for _, acct := range batch { + if _, ok := ethAddresses[acct]; !ok { + fmt.Printf("Skipping account %s: no associated eth address found\n", acct.String()) + continue + } + closeInstruction := claimable_tokens.NewCloseInstructionBuilder(). + SetUserBank(acct). + SetAuthority(authority). + SetDestination(destination). + SetEthAddress(ethAddresses[acct]) + instructions = append(instructions, closeInstruction.Build()) + + } + + if len(instructions) == 0 { + fmt.Println("No valid accounts to process in this batch.") + return nil, nil + } + + blockhashResult, err := rpcClient.GetLatestBlockhash(ctx, rpc.CommitmentFinalized) + if err != nil { + return nil, fmt.Errorf("error getting recent blockhash: %v", err) + } + recentBlockhash := blockhashResult.Value.Blockhash + + tx, err := solana.NewTransaction( + instructions, + recentBlockhash, + solana.TransactionPayer(keypair.PublicKey()), + ) + if err != nil { + return nil, fmt.Errorf("error building transaction: %v", err) + } + + tx.Sign(func(key solana.PublicKey) *solana.PrivateKey { + if key.Equals(keypair.PublicKey()) { + return &keypair + } + return nil + }) + + txSig, err := rpcClient.SendTransaction(ctx, tx) + if err != nil { + return nil, fmt.Errorf("error sending transaction: %v", err) + } + return &txSig, nil +} + +func getEmptyTokenAccounts(ctx context.Context, client *rpc.Client, mint solana.PublicKey, owner solana.PublicKey) (rpc.GetProgramAccountsV2Result, error) { + mintOffset := uint64(0) + ownerOffset := uint64(32) + balanceOffset := uint64(64) + balance := make([]byte, 8) + + dataSliceOffset := uint64(0) + dataSliceLength := uint64(0) + limit := uint64(1000) + + return client.GetProgramAccountsV2WithOpts(ctx, solana.TokenProgramID, &rpc.GetProgramAccountsV2Opts{ + GetProgramAccountsOpts: rpc.GetProgramAccountsOpts{ + DataSlice: &rpc.DataSlice{ + Offset: &dataSliceOffset, + Length: &dataSliceLength, + }, + Filters: []rpc.RPCFilter{ + { + Memcmp: &rpc.RPCFilterMemcmp{ + Offset: mintOffset, + Bytes: mint[:], + }, + }, + { + Memcmp: &rpc.RPCFilterMemcmp{ + Offset: ownerOffset, + Bytes: owner[:], + }, + }, + { + Memcmp: &rpc.RPCFilterMemcmp{ + Offset: balanceOffset, + Bytes: balance, + }, + }, + { + DataSize: 165, // Standard SPL Token account size + }, + }, + }, + Limit: &limit, + }) +} + +func getEthAddressesFromAccounts(ctx context.Context, pool *pgxpool.Pool, accounts []solana.PublicKey) (map[solana.PublicKey]common.Address, error) { + sql := ` + SELECT account, ethereum_address + FROM sol_claimable_accounts + WHERE account = ANY($1) + ` + rows, err := pool.Query(ctx, sql, accounts) + if err != nil { + return nil, fmt.Errorf("failed to query eth addresses: %w", err) + } + defer rows.Close() + + result := make(map[solana.PublicKey]common.Address) + for rows.Next() { + var account string + var ethAddress string + if err := rows.Scan(&account, ðAddress); err != nil { + return nil, fmt.Errorf("failed to scan row: %w", err) + } + result[solana.MustPublicKeyFromBase58(account)] = common.HexToAddress(ethAddress) + } + return result, nil +} diff --git a/go.mod b/go.mod index ea25e267..7f303314 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.3 require ( connectrpc.com/connect v1.18.1 + github.com/AlecAivazis/survey/v2 v2.3.7 github.com/Doist/unfurlist v0.0.0-20250409100812-515f2735f8e5 github.com/OpenAudio/go-openaudio v1.0.9 github.com/aquasecurity/esquery v0.2.0 @@ -34,6 +35,7 @@ require ( github.com/rpcpool/yellowstone-grpc/examples/golang v0.0.0-20250605231917-29d62ca5d4ae github.com/segmentio/encoding v0.4.1 github.com/speps/go-hashids/v2 v2.0.1 + github.com/spf13/cobra v1.10.1 github.com/stretchr/testify v1.11.1 github.com/test-go/testify v1.1.4 github.com/tidwall/gjson v1.18.0 @@ -49,7 +51,6 @@ require ( require ( filippo.io/edwards25519 v1.0.0-rc.1 // indirect - github.com/AlecAivazis/survey/v2 v2.3.7 // indirect github.com/DataDog/zstd v1.5.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/StackExchange/wmi v1.2.1 // indirect @@ -203,7 +204,6 @@ require ( github.com/shirou/gopsutil/v4 v4.25.1 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect - github.com/spf13/cobra v1.10.1 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 // indirect github.com/stretchr/objx v0.5.2 // indirect @@ -242,4 +242,4 @@ require ( rsc.io/tmplfunc v0.0.3 // indirect ) -replace github.com/gagliardetto/solana-go => github.com/rickyrombo/solana-go v1.12.1-0.20250714063439-a36a9577196d +replace github.com/gagliardetto/solana-go => github.com/rickyrombo/solana-go v0.0.0-20251201205735-fb79b7daf0e2 diff --git a/go.sum b/go.sum index 12737ebb..ce811d3d 100644 --- a/go.sum +++ b/go.sum @@ -16,13 +16,10 @@ github.com/Doist/unfurlist v0.0.0-20250409100812-515f2735f8e5 h1:H6UB7AS+PZjJIET github.com/Doist/unfurlist v0.0.0-20250409100812-515f2735f8e5/go.mod h1:0+psOGlhok8Re5d2R6X+4HYcqQ9VvMNB866cyMoA/Z8= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= -github.com/OpenAudio/go-openaudio v1.0.4 h1:A8ZnVVmyDrCZU7FL87+ROqnuhZwpz4exxAoEsIzOIOk= -github.com/OpenAudio/go-openaudio v1.0.4/go.mod h1:xmx0/cpToJenH1N+CrFgF3aApI1dJBiwoShvlwSbpHE= -github.com/OpenAudio/go-openaudio v1.0.8 h1:Pm38voEqECu5P+fAlw0b9sGZvKYbtqgFMerUPdy+NSg= -github.com/OpenAudio/go-openaudio v1.0.8/go.mod h1:xmx0/cpToJenH1N+CrFgF3aApI1dJBiwoShvlwSbpHE= github.com/OpenAudio/go-openaudio v1.0.9 h1:lyMsJz4n/UfY/nYJPVTW3evNS4ucF+65wZzUl6zVelQ= github.com/OpenAudio/go-openaudio v1.0.9/go.mod h1:xmx0/cpToJenH1N+CrFgF3aApI1dJBiwoShvlwSbpHE= github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= @@ -102,14 +99,14 @@ github.com/containerd/continuity v0.4.2 h1:v3y/4Yz5jwnvqPKJJ+7Wf93fyWoCB3F5EclWG github.com/containerd/continuity v0.4.2/go.mod h1:F6PTNCKepoxEaXLQp3wDAjygEnImnZ/7o4JzpodfroQ= github.com/cosmos/gogoproto v1.7.0 h1:79USr0oyXAbxg3rspGh/m4SWNyoz/GLaAh0QlCe2fro= github.com/cosmos/gogoproto v1.7.0/go.mod h1:yWChEv5IUEYURQasfyBW5ffkMHR/90hiHgbNgrtp4j0= -github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= -github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a h1:W8mUrRp6NOVl3J+MYp5kPMoUZPp7aOYHtaua31lwRHg= github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a/go.mod h1:sTwzHBvIzm2RfVCGNEBZgRyjwK40bVoun3ZnGOCafNM= github.com/crate-crypto/go-kzg-4844 v1.1.0 h1:EN/u9k2TF6OWSHrCCDBBU6GLNMq88OspHHlMnHfoyU4= github.com/crate-crypto/go-kzg-4844 v1.1.0/go.mod h1:JolLjpSff1tCCJKaJx4psrlEdlXuJEC996PL3tTAFks= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -341,6 +338,7 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/holiman/billy v0.0.0-20240216141850-2abb0c79d3c4 h1:X4egAf/gcS1zATw6wn4Ej8vjuVGxeHdan+bRb2ebyv4= github.com/holiman/billy v0.0.0-20240216141850-2abb0c79d3c4/go.mod h1:5GuXa7vkL8u9FkFuWdVvfR5ix8hRB7DbOAaYULamFpc= @@ -568,8 +566,8 @@ github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c h1:kmzxiX+OB0knCo1 github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c/go.mod h1:wNJrtinHyC3YSf6giEh4FJN8+yZV7nXBjvmfjhBIcw4= github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM= github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= -github.com/rickyrombo/solana-go v1.12.1-0.20250714063439-a36a9577196d h1:OfrLB8AyRslh1ui3LZe+/KuORHsjSuU2+ZOz2oJHBTM= -github.com/rickyrombo/solana-go v1.12.1-0.20250714063439-a36a9577196d/go.mod h1:l/qqqIN6qJJPtxW/G1PF4JtcE3Zg2vD2EliZrr9Gn5k= +github.com/rickyrombo/solana-go v0.0.0-20251201205735-fb79b7daf0e2 h1:xXdEPA+q2bMH9FYRyBfLc6iYhdBCwEC5zYVTPx7B9PU= +github.com/rickyrombo/solana-go v0.0.0-20251201205735-fb79b7daf0e2/go.mod h1:l/qqqIN6qJJPtxW/G1PF4JtcE3Zg2vD2EliZrr9Gn5k= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= diff --git a/solana/spl/programs/claimable_tokens/Close.go b/solana/spl/programs/claimable_tokens/Close.go new file mode 100644 index 00000000..f9dd1e6f --- /dev/null +++ b/solana/spl/programs/claimable_tokens/Close.go @@ -0,0 +1,117 @@ +package claimable_tokens + +import ( + "github.com/ethereum/go-ethereum/common" + bin "github.com/gagliardetto/binary" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/text" + "github.com/gagliardetto/solana-go/text/format" + "github.com/gagliardetto/treeout" +) + +type Close struct { + EthAddress common.Address + + solana.AccountMetaSlice `bin:"-" borsh_skip:"true"` +} + +var ( + _ solana.AccountsGettable = (*Close)(nil) + _ solana.AccountsSettable = (*Close)(nil) + _ text.EncodableToTree = (*Close)(nil) +) + +func NewCloseInstructionBuilder() *Close { + inst := &Close{ + AccountMetaSlice: make(solana.AccountMetaSlice, 4), + } + inst.AccountMetaSlice[3] = solana.NewAccountMeta(solana.TokenProgramID, false, false) + return inst +} + +func (inst *Close) SetEthAddress(ethAddress common.Address) *Close { + inst.EthAddress = ethAddress + return inst +} + +func (inst *Close) UserBank() *solana.AccountMeta { + return inst.AccountMetaSlice.Get(0) +} + +func (inst *Close) SetUserBank(userBank solana.PublicKey) *Close { + inst.AccountMetaSlice[0] = solana.NewAccountMeta(userBank, true, false) + return inst +} + +func (inst *Close) Authority() *solana.AccountMeta { + return inst.AccountMetaSlice.Get(1) +} + +func (inst *Close) SetAuthority(authority solana.PublicKey) *Close { + inst.AccountMetaSlice[1] = solana.NewAccountMeta(authority, true, false) + return inst +} + +func (inst *Close) Destination() *solana.AccountMeta { + return inst.AccountMetaSlice.Get(2) +} + +func (inst *Close) SetDestination(destination solana.PublicKey) *Close { + inst.AccountMetaSlice[2] = solana.NewAccountMeta(destination, false, true) + return inst +} + +func (inst *Close) TokenProgram() *solana.AccountMeta { + return inst.AccountMetaSlice.Get(3) +} + +func (inst *Close) SetTokenProgram(tokenProgram solana.PublicKey) *Close { + inst.AccountMetaSlice[3] = solana.NewAccountMeta(tokenProgram, false, false) + return inst +} + +func (inst *Close) CloseAuthority() *solana.AccountMeta { + if len(inst.AccountMetaSlice) < 5 { + return nil + } + return inst.AccountMetaSlice.Get(4) +} + +func (inst *Close) SetCloseAuthority(closeAuthority solana.PublicKey) *Close { + if len(inst.AccountMetaSlice) < 5 { + inst.AccountMetaSlice = append(inst.AccountMetaSlice, solana.NewAccountMeta(closeAuthority, true, true)) + return inst + } + inst.AccountMetaSlice[4] = solana.NewAccountMeta(closeAuthority, true, true) + return inst +} + +func (inst *Close) Build() *Instruction { + return &Instruction{BaseVariant: bin.BaseVariant{ + Impl: inst, + TypeID: bin.TypeIDFromUint8(Instruction_Close), + }} +} + +// ----- text.EncodableToTree Implementation ----- + +func (inst *Close) EncodeToTree(parent treeout.Branches) { + parent.Child(format.Program("ClaimableTokens", ProgramID)). + ParentFunc(func(programBranch treeout.Branches) { + programBranch.Child(format.Instruction("Close")). + ParentFunc(func(instructionBranch treeout.Branches) { + instructionBranch.Child("Params").ParentFunc(func(paramsBranch treeout.Branches) { + paramsBranch.Child(format.Param("EthAddress", inst.EthAddress)) + }) + instructionBranch.Child("Accounts").ParentFunc(func(accountsBranch treeout.Branches) { + accountsBranch.Child(format.Account("UserBank", inst.UserBank().PublicKey)) + accountsBranch.Child(format.Account("Authority", inst.Authority().PublicKey)) + accountsBranch.Child(format.Account("Destination", inst.Destination().PublicKey)) + accountsBranch.Child(format.Account("TokenProgram", inst.TokenProgram().PublicKey)) + if inst.CloseAuthority() != nil { + accountsBranch.Child(format.Account("CloseAuthority", inst.CloseAuthority().PublicKey)) + } + }) + }) + }) +} diff --git a/solana/spl/programs/claimable_tokens/CreateTokenAccount.go b/solana/spl/programs/claimable_tokens/CreateTokenAccount.go index 296394cc..9aaa2182 100644 --- a/solana/spl/programs/claimable_tokens/CreateTokenAccount.go +++ b/solana/spl/programs/claimable_tokens/CreateTokenAccount.go @@ -150,7 +150,7 @@ func NewCreateTokenAccountInstruction( mint solana.PublicKey, payer solana.PublicKey, ) (*CreateTokenAccount, error) { - authority, _, err := deriveAuthority(mint) + authority, _, err := DeriveAuthority(mint) if err != nil { return nil, err } diff --git a/solana/spl/programs/claimable_tokens/Transfer.go b/solana/spl/programs/claimable_tokens/Transfer.go index af26b22f..04d4a546 100644 --- a/solana/spl/programs/claimable_tokens/Transfer.go +++ b/solana/spl/programs/claimable_tokens/Transfer.go @@ -169,7 +169,7 @@ func NewTransferInstruction( if err != nil { return nil, err } - authority, _, err := deriveAuthority(mint) + authority, _, err := DeriveAuthority(mint) if err != nil { return nil, err } diff --git a/solana/spl/programs/claimable_tokens/accounts.go b/solana/spl/programs/claimable_tokens/accounts.go index 5cd5ebf9..afe0f21b 100644 --- a/solana/spl/programs/claimable_tokens/accounts.go +++ b/solana/spl/programs/claimable_tokens/accounts.go @@ -15,14 +15,14 @@ func deriveNonce(ethAddress common.Address, authority solana.PublicKey) (solana. return solana.FindProgramAddress([][]byte{authority.Bytes()[:32], seed}, ProgramID) } -func deriveAuthority(mint solana.PublicKey) (solana.PublicKey, uint8, error) { +func DeriveAuthority(mint solana.PublicKey) (solana.PublicKey, uint8, error) { return solana.FindProgramAddress([][]byte{mint.Bytes()[:32]}, ProgramID) } func deriveUserBankAccount(mint solana.PublicKey, ethAddress common.Address) (solana.PublicKey, error) { ethAddressBytes := ethAddress.Bytes() seed := base58.Encode(ethAddressBytes) - authority, _, err := deriveAuthority(mint) + authority, _, err := DeriveAuthority(mint) if err != nil { return solana.PublicKey{}, err } diff --git a/solana/spl/programs/claimable_tokens/instruction.go b/solana/spl/programs/claimable_tokens/instruction.go index bd016456..a1101274 100644 --- a/solana/spl/programs/claimable_tokens/instruction.go +++ b/solana/spl/programs/claimable_tokens/instruction.go @@ -19,6 +19,8 @@ const ( const ( Instruction_CreateTokenAccount uint8 = iota Instruction_Transfer + Instruction_SetAuthority + Instruction_Close ) // Represents a ClaimableTokens program instruction From e8b40bfead9eb1387b41c51ef86084619fc7fedb Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Mon, 1 Dec 2025 14:17:32 -0800 Subject: [PATCH 2/6] fix destination meta --- solana/spl/programs/claimable_tokens/Close.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/solana/spl/programs/claimable_tokens/Close.go b/solana/spl/programs/claimable_tokens/Close.go index f9dd1e6f..84e6c7e7 100644 --- a/solana/spl/programs/claimable_tokens/Close.go +++ b/solana/spl/programs/claimable_tokens/Close.go @@ -57,7 +57,7 @@ func (inst *Close) Destination() *solana.AccountMeta { } func (inst *Close) SetDestination(destination solana.PublicKey) *Close { - inst.AccountMetaSlice[2] = solana.NewAccountMeta(destination, false, true) + inst.AccountMetaSlice[2] = solana.NewAccountMeta(destination, true, false) return inst } From fb5be2f7bf6e6ab83bfa823d034e49699d215fc9 Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Mon, 1 Dec 2025 14:18:40 -0800 Subject: [PATCH 3/6] make program id configurable --- cmd/reclaim_rent/main.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cmd/reclaim_rent/main.go b/cmd/reclaim_rent/main.go index ea0d9e93..f5b054b2 100644 --- a/cmd/reclaim_rent/main.go +++ b/cmd/reclaim_rent/main.go @@ -33,6 +33,7 @@ func init() { reclaimRentCmd.Flags().StringP("database", "c", "postgres://postgres:postgres@localhost:5432/discovery_provider_1?sslmode=disable", "Database connection string") reclaimRentCmd.Flags().StringP("keypair", "k", "~/.config/solana/id.json", "The wallet to use as fee payer for transactions") reclaimRentCmd.Flags().StringP("destination", "d", "", "The recipient of reclaimed rent (defaults to fee payer)") + reclaimRentCmd.Flags().StringP("program", "p", claimable_tokens.ProgramID.String(), "The claimable tokens program ID") } func reclaimRent(cmd *cobra.Command, args []string) error { @@ -79,6 +80,12 @@ func reclaimRent(cmd *cobra.Command, args []string) error { destination = solana.MustPublicKeyFromBase58(destinationFlag) } + programIDFlag, err := cmd.Flags().GetString("program") + if err != nil { + return fmt.Errorf("failed to get program flag: %w", err) + } + claimable_tokens.SetProgramID(solana.MustPublicKeyFromBase58(programIDFlag)) + mint := solana.MustPublicKeyFromBase58(args[0]) fmt.Println("Reclaiming rent for mint:", args[0]) From 0096e1f8047dae28af1bcefdf2336fcf539422c1 Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Mon, 1 Dec 2025 15:47:53 -0800 Subject: [PATCH 4/6] Fix script pagination --- cmd/reclaim_rent/main.go | 24 ++++++++++++++++-------- config/solana_config.go | 4 ++-- go.mod | 2 +- go.sum | 4 ++-- 4 files changed, 21 insertions(+), 13 deletions(-) diff --git a/cmd/reclaim_rent/main.go b/cmd/reclaim_rent/main.go index f5b054b2..a96205d1 100644 --- a/cmd/reclaim_rent/main.go +++ b/cmd/reclaim_rent/main.go @@ -95,18 +95,21 @@ func reclaimRent(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to derive authority: %w", err) } + var pageKey *string + for { - res, err := getEmptyTokenAccounts(ctx, rpcClient, mint, authority) + res, err := getEmptyTokenAccounts(ctx, rpcClient, mint, authority, pageKey) if err != nil { return fmt.Errorf("failed to get token accounts: %w", err) } - if res.PaginationKey == nil { - fmt.Println("No more empty token accounts to process.") - break + pageKey = res.PaginationKey + safePageKey := "" + if pageKey != nil { + safePageKey = *pageKey } - fmt.Printf("Found %d empty token accounts for mint %s owned by authority %s\n", len(res.Accounts), mint.String(), authority.String()) + fmt.Printf("Found %d empty token accounts for mint %s owned by authority %s on page %s\n", len(res.Accounts), mint.String(), authority.String(), safePageKey) i := 0 batchSize := 15 @@ -134,6 +137,10 @@ func reclaimRent(cmd *cobra.Command, args []string) error { fmt.Println("Processed one batch, exiting for now.") return nil } + + if pageKey == nil { + break + } } return nil } @@ -193,7 +200,7 @@ func processBatch(ctx context.Context, pool *pgxpool.Pool, rpcClient *rpc.Client return &txSig, nil } -func getEmptyTokenAccounts(ctx context.Context, client *rpc.Client, mint solana.PublicKey, owner solana.PublicKey) (rpc.GetProgramAccountsV2Result, error) { +func getEmptyTokenAccounts(ctx context.Context, client *rpc.Client, mint solana.PublicKey, owner solana.PublicKey, pageKey *string) (rpc.GetProgramAccountsV2Result, error) { mintOffset := uint64(0) ownerOffset := uint64(32) balanceOffset := uint64(64) @@ -201,7 +208,7 @@ func getEmptyTokenAccounts(ctx context.Context, client *rpc.Client, mint solana. dataSliceOffset := uint64(0) dataSliceLength := uint64(0) - limit := uint64(1000) + limit := uint64(10000) return client.GetProgramAccountsV2WithOpts(ctx, solana.TokenProgramID, &rpc.GetProgramAccountsV2Opts{ GetProgramAccountsOpts: rpc.GetProgramAccountsOpts{ @@ -233,7 +240,8 @@ func getEmptyTokenAccounts(ctx context.Context, client *rpc.Client, mint solana. }, }, }, - Limit: &limit, + PaginationKey: pageKey, + Limit: &limit, }) } diff --git a/config/solana_config.go b/config/solana_config.go index 054e9874..97f380f6 100644 --- a/config/solana_config.go +++ b/config/solana_config.go @@ -49,7 +49,7 @@ const ( // Stage StageSolanaRelay = "https://discoveryprovider.staging.audius.co/solana/relay" StageMintAudio = "9LzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM" - StageMintUSDC = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZy4z6cQ" + StageMintUSDC = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" StageRewardManagerProgramID = "CDpzvz7DfgbF95jSSCHLX3ERkugyfgn9Fw8ypNZ1hfXp" StageRewardManagerState = "GaiG9LDYHfZGqeNaoGRzFEnLiwUT7WiC6sA6FDJX9ZPq" StageRewardManagerLookupTable = "ChFCWjeFxM6SRySTfT46zXn2K7m89TJsft4HWzEtkB4J" @@ -60,7 +60,7 @@ const ( // Prod ProdSolanaRelay = "https://discoveryprovider.audius.co/solana/relay" ProdMintAudio = "9LzCMqDgTKYz9Drzqnpgee3SGa89up3a247ypMj2xrqM" - ProdMintUSDC = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZy4z6cQ" + ProdMintUSDC = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" ProdRewardManagerProgramID = "DDZDcYdQFEMwcu2Mwo75yGFjJ1mUQyyXLWzhZLEVFcei" ProdRewardManagerState = "71hWFVYokLaN1PNYzTAWi13EfJ7Xt9VbSWUKsXUT8mxE" ProdRewardManagerLookupTable = "4UQwpGupH66RgQrWRqmPM9Two6VJEE68VZ7GeqZ3mvVv" diff --git a/go.mod b/go.mod index 7f303314..6fe149ca 100644 --- a/go.mod +++ b/go.mod @@ -242,4 +242,4 @@ require ( rsc.io/tmplfunc v0.0.3 // indirect ) -replace github.com/gagliardetto/solana-go => github.com/rickyrombo/solana-go v0.0.0-20251201205735-fb79b7daf0e2 +replace github.com/gagliardetto/solana-go => github.com/rickyrombo/solana-go v0.0.0-20251201234416-e59646f7798f diff --git a/go.sum b/go.sum index ce811d3d..1c72cc25 100644 --- a/go.sum +++ b/go.sum @@ -566,8 +566,8 @@ github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c h1:kmzxiX+OB0knCo1 github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c/go.mod h1:wNJrtinHyC3YSf6giEh4FJN8+yZV7nXBjvmfjhBIcw4= github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM= github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= -github.com/rickyrombo/solana-go v0.0.0-20251201205735-fb79b7daf0e2 h1:xXdEPA+q2bMH9FYRyBfLc6iYhdBCwEC5zYVTPx7B9PU= -github.com/rickyrombo/solana-go v0.0.0-20251201205735-fb79b7daf0e2/go.mod h1:l/qqqIN6qJJPtxW/G1PF4JtcE3Zg2vD2EliZrr9Gn5k= +github.com/rickyrombo/solana-go v0.0.0-20251201234416-e59646f7798f h1:Wa9G8Lq44qqMUhPtggYE4Pf8cJE7r0yZ8EuInLDBPxU= +github.com/rickyrombo/solana-go v0.0.0-20251201234416-e59646f7798f/go.mod h1:l/qqqIN6qJJPtxW/G1PF4JtcE3Zg2vD2EliZrr9Gn5k= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= From ec5c7fd4dbd959f605628f2b1a41df3e357d0a8f Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Mon, 1 Dec 2025 16:50:23 -0800 Subject: [PATCH 5/6] Use db like a chump --- cmd/reclaim_rent/main.go | 129 ++++++++++++++++++++++++--------------- 1 file changed, 79 insertions(+), 50 deletions(-) diff --git a/cmd/reclaim_rent/main.go b/cmd/reclaim_rent/main.go index a96205d1..cfae75bf 100644 --- a/cmd/reclaim_rent/main.go +++ b/cmd/reclaim_rent/main.go @@ -9,8 +9,11 @@ import ( "github.com/spf13/cobra" "api.audius.co/solana/spl/programs/claimable_tokens" + bin "github.com/gagliardetto/binary" "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/programs/token" "github.com/gagliardetto/solana-go/rpc" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) @@ -95,34 +98,39 @@ func reclaimRent(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to derive authority: %w", err) } - var pageKey *string + offset := 0 for { - res, err := getEmptyTokenAccounts(ctx, rpcClient, mint, authority, pageKey) + accounts, err := getTokenAccountsFromDatabase(ctx, pool, mint, 1000, offset) if err != nil { - return fmt.Errorf("failed to get token accounts: %w", err) + return fmt.Errorf("failed to get token accounts from database: %w", err) } - pageKey = res.PaginationKey - safePageKey := "" - if pageKey != nil { - safePageKey = *pageKey + if len(accounts) == 0 { + fmt.Println("No more accounts to process.") + break } + fmt.Printf("Gathered %d accounts from db\n", len(accounts)) - fmt.Printf("Found %d empty token accounts for mint %s owned by authority %s on page %s\n", len(res.Accounts), mint.String(), authority.String(), safePageKey) + offset += len(accounts) + + filtered, err := filterAccounts(ctx, rpcClient, accounts) + if err != nil { + return fmt.Errorf("failed to filter accounts: %w", err) + } - i := 0 batchSize := 15 + i := 0 for { - batch := make([]solana.PublicKey, 0, batchSize) - for j := i; j < i+batchSize && j < len(res.Accounts); j++ { - batch = append(batch, res.Accounts[j].Pubkey) + batch := make([]DatabaseAccount, 0, batchSize) + for j := i; j < i+batchSize && j < len(filtered); j++ { + batch = append(batch, filtered[j]) } if len(batch) == 0 { break } - txSig, err := processBatch(ctx, pool, rpcClient, batch, authority, destination, keypair) + txSig, err := processBatch(ctx, rpcClient, batch, authority, destination, keypair) if err != nil { return fmt.Errorf("failed to process batch: %w", err) } @@ -131,39 +139,57 @@ func reclaimRent(cmd *cobra.Command, args []string) error { } time.Sleep(time.Second / 500 * 2) // Max 500 req/s (2 req per batch) to avoid rate limiting + fmt.Printf("Processed %d/%d accounts\n", i+len(batch), len(filtered)) i += batchSize - - // TODO: do more than one batch - fmt.Println("Processed one batch, exiting for now.") - return nil - } - - if pageKey == nil { - break } } return nil } -func processBatch(ctx context.Context, pool *pgxpool.Pool, rpcClient *rpc.Client, batch []solana.PublicKey, authority solana.PublicKey, destination solana.PublicKey, keypair solana.PrivateKey) (*solana.Signature, error) { - ethAddresses, err := getEthAddressesFromAccounts(ctx, pool, batch) +func filterAccounts(ctx context.Context, rpcClient *rpc.Client, batch []DatabaseAccount) ([]DatabaseAccount, error) { + accounts := make([]solana.PublicKey, 0, len(batch)) + for _, acct := range batch { + accounts = append(accounts, solana.MustPublicKeyFromBase58(acct.Account)) + } + + res, err := rpcClient.GetMultipleAccountsWithOpts(ctx, accounts, &rpc.GetMultipleAccountsOpts{ + Encoding: solana.EncodingBase64, + }) if err != nil { - return nil, fmt.Errorf("failed to get ETH addresses: %w", err) + return nil, fmt.Errorf("failed to get accounts: %w", err) } - instructions := make([]solana.Instruction, 0, len(batch)) - for _, acct := range batch { - if _, ok := ethAddresses[acct]; !ok { - fmt.Printf("Skipping account %s: no associated eth address found\n", acct.String()) + filtered := make([]DatabaseAccount, 0, len(batch)) + + for i, acct := range res.Value { + if acct == nil { + fmt.Printf("Skipping account %s: account does not exist\n", batch[i].Account) continue } + var tokenAccount token.Account + err := bin.NewBorshDecoder(acct.Data.GetBinary()).Decode(&tokenAccount) + if err != nil { + fmt.Printf("Skipping account %s: failed to decode account data (%v)\n", batch[i].Account, err) + continue + } + if tokenAccount.Amount != 0 { + fmt.Printf("Skipping account %s: account balance is not zero\n", batch[i].Account) + continue + } + filtered = append(filtered, batch[i]) + } + return filtered, nil +} + +func processBatch(ctx context.Context, rpcClient *rpc.Client, batch []DatabaseAccount, authority solana.PublicKey, destination solana.PublicKey, keypair solana.PrivateKey) (*solana.Signature, error) { + instructions := make([]solana.Instruction, 0, len(batch)) + for _, acct := range batch { closeInstruction := claimable_tokens.NewCloseInstructionBuilder(). - SetUserBank(acct). + SetUserBank(solana.MustPublicKeyFromBase58(acct.Account)). SetAuthority(authority). SetDestination(destination). - SetEthAddress(ethAddresses[acct]) + SetEthAddress(common.HexToAddress(acct.EthereumAddress)) instructions = append(instructions, closeInstruction.Build()) - } if len(instructions) == 0 { @@ -193,9 +219,12 @@ func processBatch(ctx context.Context, pool *pgxpool.Pool, rpcClient *rpc.Client return nil }) - txSig, err := rpcClient.SendTransaction(ctx, tx) + txSig := tx.Signatures[0] + _, err = rpcClient.SendTransactionWithOpts(ctx, tx, rpc.TransactionOpts{ + SkipPreflight: true, + }) if err != nil { - return nil, fmt.Errorf("error sending transaction: %v", err) + return nil, fmt.Errorf("error sending transaction %s: %v", txSig.String(), err) } return &txSig, nil } @@ -245,26 +274,26 @@ func getEmptyTokenAccounts(ctx context.Context, client *rpc.Client, mint solana. }) } -func getEthAddressesFromAccounts(ctx context.Context, pool *pgxpool.Pool, accounts []solana.PublicKey) (map[solana.PublicKey]common.Address, error) { +type DatabaseAccount struct { + Account string + EthereumAddress string +} + +func getTokenAccountsFromDatabase(ctx context.Context, pool *pgxpool.Pool, mint solana.PublicKey, limit, offset int) ([]DatabaseAccount, error) { sql := ` - SELECT account, ethereum_address - FROM sol_claimable_accounts - WHERE account = ANY($1) + SELECT bank_account AS account, ethereum_address + FROM user_bank_accounts + LIMIT $1 OFFSET $2 ` - rows, err := pool.Query(ctx, sql, accounts) + rows, err := pool.Query(ctx, sql, limit, offset) if err != nil { - return nil, fmt.Errorf("failed to query eth addresses: %w", err) + return nil, fmt.Errorf("failed to query token accounts: %w", err) } - defer rows.Close() - - result := make(map[solana.PublicKey]common.Address) - for rows.Next() { - var account string - var ethAddress string - if err := rows.Scan(&account, ðAddress); err != nil { - return nil, fmt.Errorf("failed to scan row: %w", err) - } - result[solana.MustPublicKeyFromBase58(account)] = common.HexToAddress(ethAddress) + + accounts, err := pgx.CollectRows(rows, pgx.RowToStructByName[DatabaseAccount]) + if err != nil { + return nil, fmt.Errorf("failed to collect token accounts: %w", err) } - return result, nil + + return accounts, nil } From c37fca5c7fabea13ca28bea9434f19421483eed7 Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Mon, 1 Dec 2025 17:03:17 -0800 Subject: [PATCH 6/6] count pagination --- cmd/reclaim_rent/main.go | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/cmd/reclaim_rent/main.go b/cmd/reclaim_rent/main.go index cfae75bf..5ffff268 100644 --- a/cmd/reclaim_rent/main.go +++ b/cmd/reclaim_rent/main.go @@ -100,8 +100,15 @@ func reclaimRent(cmd *cobra.Command, args []string) error { offset := 0 + totalCount, err := getTokenAccountsCountFromDatabase(ctx, pool, mint) + if err != nil { + return fmt.Errorf("failed to get token accounts count from database: %w", err) + } + + limit := 1000 + for { - accounts, err := getTokenAccountsFromDatabase(ctx, pool, mint, 1000, offset) + accounts, err := getTokenAccountsFromDatabase(ctx, pool, mint, limit, offset) if err != nil { return fmt.Errorf("failed to get token accounts from database: %w", err) } @@ -139,7 +146,7 @@ func reclaimRent(cmd *cobra.Command, args []string) error { } time.Sleep(time.Second / 500 * 2) // Max 500 req/s (2 req per batch) to avoid rate limiting - fmt.Printf("Processed %d/%d accounts\n", i+len(batch), len(filtered)) + fmt.Printf("Processed %d/%d accounts (%d/%d)\n", i+len(batch), len(filtered), offset/limit+1, (totalCount+999)/limit) i += batchSize } } @@ -297,3 +304,16 @@ func getTokenAccountsFromDatabase(ctx context.Context, pool *pgxpool.Pool, mint return accounts, nil } + +func getTokenAccountsCountFromDatabase(ctx context.Context, pool *pgxpool.Pool, mint solana.PublicKey) (int, error) { + sql := ` + SELECT COUNT(*) + FROM user_bank_accounts + ` + var count int + err := pool.QueryRow(ctx, sql).Scan(&count) + if err != nil { + return 0, fmt.Errorf("failed to query token accounts count: %w", err) + } + return count, nil +}