Skip to content

Commit cbbcf0b

Browse files
authored
Create script for reclaiming rent (#586)
1 parent b03292e commit cbbcf0b

File tree

8 files changed

+451
-15
lines changed

8 files changed

+451
-15
lines changed

cmd/reclaim_rent/main.go

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
"github.com/ethereum/go-ethereum/common"
9+
"github.com/spf13/cobra"
10+
11+
"api.audius.co/solana/spl/programs/claimable_tokens"
12+
bin "github.com/gagliardetto/binary"
13+
"github.com/gagliardetto/solana-go"
14+
"github.com/gagliardetto/solana-go/programs/token"
15+
"github.com/gagliardetto/solana-go/rpc"
16+
"github.com/jackc/pgx/v5"
17+
"github.com/jackc/pgx/v5/pgxpool"
18+
)
19+
20+
var reclaimRentCmd = &cobra.Command{
21+
Use: "reclaim_rent <mint>",
22+
Short: "Reclaim rent from empty token accounts for a given mint",
23+
Args: cobra.ExactArgs(1),
24+
RunE: reclaimRent,
25+
}
26+
27+
func main() {
28+
err := reclaimRentCmd.Execute()
29+
if err != nil {
30+
fmt.Println("Error executing command:", err)
31+
}
32+
}
33+
34+
func init() {
35+
reclaimRentCmd.Flags().StringP("rpc", "r", "https://api.mainnet-beta.solana.com", "The Solana RPC endpoint to use")
36+
reclaimRentCmd.Flags().StringP("database", "c", "postgres://postgres:postgres@localhost:5432/discovery_provider_1?sslmode=disable", "Database connection string")
37+
reclaimRentCmd.Flags().StringP("keypair", "k", "~/.config/solana/id.json", "The wallet to use as fee payer for transactions")
38+
reclaimRentCmd.Flags().StringP("destination", "d", "", "The recipient of reclaimed rent (defaults to fee payer)")
39+
reclaimRentCmd.Flags().StringP("program", "p", claimable_tokens.ProgramID.String(), "The claimable tokens program ID")
40+
}
41+
42+
func reclaimRent(cmd *cobra.Command, args []string) error {
43+
ctx := context.Background()
44+
45+
rpcEndpoint, err := cmd.Flags().GetString("rpc")
46+
if err != nil {
47+
return fmt.Errorf("failed to get rpc flag: %w", err)
48+
}
49+
rpcClient := rpc.New(rpcEndpoint)
50+
51+
databaseURL, err := cmd.Flags().GetString("database")
52+
if err != nil {
53+
return fmt.Errorf("failed to get database flag: %w", err)
54+
}
55+
56+
config, err := pgxpool.ParseConfig(databaseURL)
57+
if err != nil {
58+
return fmt.Errorf("failed to parse database URL: %w", err)
59+
}
60+
pool, err := pgxpool.NewWithConfig(ctx, config)
61+
if err != nil {
62+
return fmt.Errorf("failed to create database pool: %w", err)
63+
}
64+
defer pool.Close()
65+
66+
feePayerFlag, err := cmd.Flags().GetString("keypair")
67+
if err != nil {
68+
return fmt.Errorf("failed to get keypair flag: %w", err)
69+
}
70+
keypair, err := solana.PrivateKeyFromSolanaKeygenFile(feePayerFlag)
71+
if err != nil {
72+
return fmt.Errorf("failed to load keypair: %w", err)
73+
}
74+
75+
destinationFlag, err := cmd.Flags().GetString("destination")
76+
if err != nil {
77+
return fmt.Errorf("failed to get destination flag: %w", err)
78+
}
79+
var destination solana.PublicKey
80+
if destinationFlag == "" {
81+
destination = keypair.PublicKey()
82+
} else {
83+
destination = solana.MustPublicKeyFromBase58(destinationFlag)
84+
}
85+
86+
programIDFlag, err := cmd.Flags().GetString("program")
87+
if err != nil {
88+
return fmt.Errorf("failed to get program flag: %w", err)
89+
}
90+
claimable_tokens.SetProgramID(solana.MustPublicKeyFromBase58(programIDFlag))
91+
92+
mint := solana.MustPublicKeyFromBase58(args[0])
93+
94+
fmt.Println("Reclaiming rent for mint:", args[0])
95+
96+
authority, _, err := claimable_tokens.DeriveAuthority(mint)
97+
if err != nil {
98+
return fmt.Errorf("failed to derive authority: %w", err)
99+
}
100+
101+
offset := 0
102+
103+
totalCount, err := getTokenAccountsCountFromDatabase(ctx, pool, mint)
104+
if err != nil {
105+
return fmt.Errorf("failed to get token accounts count from database: %w", err)
106+
}
107+
108+
limit := 1000
109+
110+
for {
111+
accounts, err := getTokenAccountsFromDatabase(ctx, pool, mint, limit, offset)
112+
if err != nil {
113+
return fmt.Errorf("failed to get token accounts from database: %w", err)
114+
}
115+
116+
if len(accounts) == 0 {
117+
fmt.Println("No more accounts to process.")
118+
break
119+
}
120+
fmt.Printf("Gathered %d accounts from db\n", len(accounts))
121+
122+
offset += len(accounts)
123+
124+
filtered, err := filterAccounts(ctx, rpcClient, accounts)
125+
if err != nil {
126+
return fmt.Errorf("failed to filter accounts: %w", err)
127+
}
128+
129+
batchSize := 15
130+
i := 0
131+
132+
for {
133+
batch := make([]DatabaseAccount, 0, batchSize)
134+
for j := i; j < i+batchSize && j < len(filtered); j++ {
135+
batch = append(batch, filtered[j])
136+
}
137+
if len(batch) == 0 {
138+
break
139+
}
140+
txSig, err := processBatch(ctx, rpcClient, batch, authority, destination, keypair)
141+
if err != nil {
142+
return fmt.Errorf("failed to process batch: %w", err)
143+
}
144+
if txSig != nil {
145+
fmt.Printf("Submitted transaction %s to reclaim rent for %d accounts\n", txSig.String(), len(batch))
146+
}
147+
time.Sleep(time.Second / 500 * 2) // Max 500 req/s (2 req per batch) to avoid rate limiting
148+
149+
fmt.Printf("Processed %d/%d accounts (%d/%d)\n", i+len(batch), len(filtered), offset/limit+1, (totalCount+999)/limit)
150+
i += batchSize
151+
}
152+
}
153+
return nil
154+
}
155+
156+
func filterAccounts(ctx context.Context, rpcClient *rpc.Client, batch []DatabaseAccount) ([]DatabaseAccount, error) {
157+
accounts := make([]solana.PublicKey, 0, len(batch))
158+
for _, acct := range batch {
159+
accounts = append(accounts, solana.MustPublicKeyFromBase58(acct.Account))
160+
}
161+
162+
res, err := rpcClient.GetMultipleAccountsWithOpts(ctx, accounts, &rpc.GetMultipleAccountsOpts{
163+
Encoding: solana.EncodingBase64,
164+
})
165+
if err != nil {
166+
return nil, fmt.Errorf("failed to get accounts: %w", err)
167+
}
168+
169+
filtered := make([]DatabaseAccount, 0, len(batch))
170+
171+
for i, acct := range res.Value {
172+
if acct == nil {
173+
fmt.Printf("Skipping account %s: account does not exist\n", batch[i].Account)
174+
continue
175+
}
176+
var tokenAccount token.Account
177+
err := bin.NewBorshDecoder(acct.Data.GetBinary()).Decode(&tokenAccount)
178+
if err != nil {
179+
fmt.Printf("Skipping account %s: failed to decode account data (%v)\n", batch[i].Account, err)
180+
continue
181+
}
182+
if tokenAccount.Amount != 0 {
183+
fmt.Printf("Skipping account %s: account balance is not zero\n", batch[i].Account)
184+
continue
185+
}
186+
filtered = append(filtered, batch[i])
187+
}
188+
return filtered, nil
189+
}
190+
191+
func processBatch(ctx context.Context, rpcClient *rpc.Client, batch []DatabaseAccount, authority solana.PublicKey, destination solana.PublicKey, keypair solana.PrivateKey) (*solana.Signature, error) {
192+
instructions := make([]solana.Instruction, 0, len(batch))
193+
for _, acct := range batch {
194+
closeInstruction := claimable_tokens.NewCloseInstructionBuilder().
195+
SetUserBank(solana.MustPublicKeyFromBase58(acct.Account)).
196+
SetAuthority(authority).
197+
SetDestination(destination).
198+
SetEthAddress(common.HexToAddress(acct.EthereumAddress))
199+
instructions = append(instructions, closeInstruction.Build())
200+
}
201+
202+
if len(instructions) == 0 {
203+
fmt.Println("No valid accounts to process in this batch.")
204+
return nil, nil
205+
}
206+
207+
blockhashResult, err := rpcClient.GetLatestBlockhash(ctx, rpc.CommitmentFinalized)
208+
if err != nil {
209+
return nil, fmt.Errorf("error getting recent blockhash: %v", err)
210+
}
211+
recentBlockhash := blockhashResult.Value.Blockhash
212+
213+
tx, err := solana.NewTransaction(
214+
instructions,
215+
recentBlockhash,
216+
solana.TransactionPayer(keypair.PublicKey()),
217+
)
218+
if err != nil {
219+
return nil, fmt.Errorf("error building transaction: %v", err)
220+
}
221+
222+
tx.Sign(func(key solana.PublicKey) *solana.PrivateKey {
223+
if key.Equals(keypair.PublicKey()) {
224+
return &keypair
225+
}
226+
return nil
227+
})
228+
229+
txSig := tx.Signatures[0]
230+
_, err = rpcClient.SendTransactionWithOpts(ctx, tx, rpc.TransactionOpts{
231+
SkipPreflight: true,
232+
})
233+
if err != nil {
234+
return nil, fmt.Errorf("error sending transaction %s: %v", txSig.String(), err)
235+
}
236+
return &txSig, nil
237+
}
238+
239+
func getEmptyTokenAccounts(ctx context.Context, client *rpc.Client, mint solana.PublicKey, owner solana.PublicKey, pageKey *string) (rpc.GetProgramAccountsV2Result, error) {
240+
mintOffset := uint64(0)
241+
ownerOffset := uint64(32)
242+
balanceOffset := uint64(64)
243+
balance := make([]byte, 8)
244+
245+
dataSliceOffset := uint64(0)
246+
dataSliceLength := uint64(0)
247+
limit := uint64(10000)
248+
249+
return client.GetProgramAccountsV2WithOpts(ctx, solana.TokenProgramID, &rpc.GetProgramAccountsV2Opts{
250+
GetProgramAccountsOpts: rpc.GetProgramAccountsOpts{
251+
DataSlice: &rpc.DataSlice{
252+
Offset: &dataSliceOffset,
253+
Length: &dataSliceLength,
254+
},
255+
Filters: []rpc.RPCFilter{
256+
{
257+
Memcmp: &rpc.RPCFilterMemcmp{
258+
Offset: mintOffset,
259+
Bytes: mint[:],
260+
},
261+
},
262+
{
263+
Memcmp: &rpc.RPCFilterMemcmp{
264+
Offset: ownerOffset,
265+
Bytes: owner[:],
266+
},
267+
},
268+
{
269+
Memcmp: &rpc.RPCFilterMemcmp{
270+
Offset: balanceOffset,
271+
Bytes: balance,
272+
},
273+
},
274+
{
275+
DataSize: 165, // Standard SPL Token account size
276+
},
277+
},
278+
},
279+
PaginationKey: pageKey,
280+
Limit: &limit,
281+
})
282+
}
283+
284+
type DatabaseAccount struct {
285+
Account string
286+
EthereumAddress string
287+
}
288+
289+
func getTokenAccountsFromDatabase(ctx context.Context, pool *pgxpool.Pool, mint solana.PublicKey, limit, offset int) ([]DatabaseAccount, error) {
290+
sql := `
291+
SELECT bank_account AS account, ethereum_address
292+
FROM user_bank_accounts
293+
LIMIT $1 OFFSET $2
294+
`
295+
rows, err := pool.Query(ctx, sql, limit, offset)
296+
if err != nil {
297+
return nil, fmt.Errorf("failed to query token accounts: %w", err)
298+
}
299+
300+
accounts, err := pgx.CollectRows(rows, pgx.RowToStructByName[DatabaseAccount])
301+
if err != nil {
302+
return nil, fmt.Errorf("failed to collect token accounts: %w", err)
303+
}
304+
305+
return accounts, nil
306+
}
307+
308+
func getTokenAccountsCountFromDatabase(ctx context.Context, pool *pgxpool.Pool, mint solana.PublicKey) (int, error) {
309+
sql := `
310+
SELECT COUNT(*)
311+
FROM user_bank_accounts
312+
`
313+
var count int
314+
err := pool.QueryRow(ctx, sql).Scan(&count)
315+
if err != nil {
316+
return 0, fmt.Errorf("failed to query token accounts count: %w", err)
317+
}
318+
return count, nil
319+
}

go.mod

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ go 1.25.3
44

55
require (
66
connectrpc.com/connect v1.18.1
7+
github.com/AlecAivazis/survey/v2 v2.3.7
78
github.com/Doist/unfurlist v0.0.0-20250409100812-515f2735f8e5
89
github.com/OpenAudio/go-openaudio v1.0.9
910
github.com/aquasecurity/esquery v0.2.0
@@ -34,6 +35,7 @@ require (
3435
github.com/rpcpool/yellowstone-grpc/examples/golang v0.0.0-20250605231917-29d62ca5d4ae
3536
github.com/segmentio/encoding v0.4.1
3637
github.com/speps/go-hashids/v2 v2.0.1
38+
github.com/spf13/cobra v1.10.1
3739
github.com/stretchr/testify v1.11.1
3840
github.com/test-go/testify v1.1.4
3941
github.com/tidwall/gjson v1.18.0
@@ -49,7 +51,6 @@ require (
4951

5052
require (
5153
filippo.io/edwards25519 v1.0.0-rc.1 // indirect
52-
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
5354
github.com/DataDog/zstd v1.5.2 // indirect
5455
github.com/Microsoft/go-winio v0.6.2 // indirect
5556
github.com/StackExchange/wmi v1.2.1 // indirect
@@ -204,7 +205,6 @@ require (
204205
github.com/shirou/gopsutil/v4 v4.25.1 // indirect
205206
github.com/shopspring/decimal v1.4.0 // indirect
206207
github.com/spaolacci/murmur3 v1.1.0 // indirect
207-
github.com/spf13/cobra v1.10.1 // indirect
208208
github.com/spf13/pflag v1.0.10 // indirect
209209
github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 // indirect
210210
github.com/stretchr/objx v0.5.2 // indirect
@@ -243,4 +243,4 @@ require (
243243
rsc.io/tmplfunc v0.0.3 // indirect
244244
)
245245

246-
replace github.com/gagliardetto/solana-go => github.com/rickyrombo/solana-go v1.12.1-0.20250714063439-a36a9577196d
246+
replace github.com/gagliardetto/solana-go => github.com/rickyrombo/solana-go v0.0.0-20251201234416-e59646f7798f

0 commit comments

Comments
 (0)