diff --git a/cmd/reclaim_rent/main.go b/cmd/reclaim_rent/main.go index a9f82a81..43bd5eac 100644 --- a/cmd/reclaim_rent/main.go +++ b/cmd/reclaim_rent/main.go @@ -35,7 +35,7 @@ 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)") + reclaimRentCmd.Flags().StringP("destination", "d", claimable_tokens.DefaultRentDestinationAddress, "The recipient of reclaimed rent (must match the claimable-tokens program)") reclaimRentCmd.Flags().StringP("program", "p", claimable_tokens.ProgramID.String(), "The claimable tokens program ID") reclaimRentCmd.Flags().StringP("created-after", "", "", "Filter accounts created after this date (RFC3339 format, e.g., 2024-01-01T00:00:00Z)") } @@ -77,12 +77,13 @@ func reclaimRent(cmd *cobra.Command, args []string) error { 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) + if destinationFlag != claimable_tokens.DefaultRentDestinationAddress { + return fmt.Errorf( + "destination must be %s; the claimable-tokens program rejects other addresses", + claimable_tokens.DefaultRentDestinationAddress, + ) } + destination := solana.MustPublicKeyFromBase58(destinationFlag) programIDFlag, err := cmd.Flags().GetString("program") if err != nil { diff --git a/jobs/reclaim_rent.go b/jobs/reclaim_rent.go index 5b7496af..71cb2952 100644 --- a/jobs/reclaim_rent.go +++ b/jobs/reclaim_rent.go @@ -2,6 +2,7 @@ package jobs import ( "context" + "errors" "fmt" "sync" "time" @@ -30,8 +31,8 @@ const ( type ReclaimRentJob struct { cfg config.Config pool database.DbPool - rpcClient *rpc.Client - transactionSender *spl.TransactionSender + rpcClient reclaimRentRPCClient + transactionSender reclaimRentTransactionSender mints []solana.PublicKey logger *zap.Logger @@ -39,16 +40,34 @@ type ReclaimRentJob struct { isRunning bool } +type reclaimRentRPCClient interface { + GetMultipleAccountsWithOpts( + context.Context, + []solana.PublicKey, + *rpc.GetMultipleAccountsOpts, + ) (*rpc.GetMultipleAccountsResult, error) +} + +type reclaimRentTransactionSender interface { + GetFeePayer() (*solana.Wallet, error) + SendTransactionWithRetries( + context.Context, + *solana.TransactionBuilder, + rpc.CommitmentType, + rpc.TransactionOpts, + ) (*solana.Signature, error) +} + func NewReclaimRentJob(cfg config.Config, pool database.DbPool) *ReclaimRentJob { logger := logging.NewZapLogger(cfg).Named("ReclaimRentJob") - var rpcClient *rpc.Client - if len(cfg.SolanaConfig.RpcProviders) > 0 { + var rpcClient reclaimRentRPCClient + if len(cfg.SolanaConfig.RpcProviders) > 0 && cfg.SolanaConfig.RpcProviders[0] != "" { rpcClient = rpc.New(cfg.SolanaConfig.RpcProviders[0]) } - var transactionSender *spl.TransactionSender - if len(cfg.SolanaConfig.RpcProviders) > 0 { + var transactionSender reclaimRentTransactionSender + if rpcClient != nil { transactionSender = spl.NewTransactionSender(cfg.SolanaConfig.FeePayers, cfg.SolanaConfig.RpcProviders) } @@ -102,7 +121,8 @@ func (j *ReclaimRentJob) Run(ctx context.Context) { // Closes zero-balance claimable token accounts created in the last 7 days // for the configured AUDIO and USDC mints, returning the rent lamports to the -// fee payer that signs each transaction. Ensures only one instance runs at a time. +// destination required by the claimable-tokens program. Ensures only one +// instance runs at a time. func (j *ReclaimRentJob) run(ctx context.Context) error { j.mutex.Lock() if j.isRunning { @@ -126,6 +146,7 @@ func (j *ReclaimRentJob) run(ctx context.Context) error { return nil } + var runErrors []error for _, mint := range j.mints { if mint.IsZero() { continue @@ -135,9 +156,10 @@ func (j *ReclaimRentJob) run(ctx context.Context) error { zap.String("mint", mint.String()), zap.Error(err), ) + runErrors = append(runErrors, fmt.Errorf("process mint %s: %w", mint, err)) } } - return nil + return errors.Join(runErrors...) } type reclaimRentAccount struct { @@ -145,6 +167,28 @@ type reclaimRentAccount struct { EthereumAddress string `db:"ethereum_address"` } +type reclaimRentFilterStats struct { + MissingAccount int + InvalidOwner int + InvalidData int + MintMismatch int + NonZeroBalance int + InvalidEthAddress int + AddressMismatch int + WrongCloseAuthority int +} + +func (s *reclaimRentFilterStats) add(other reclaimRentFilterStats) { + s.MissingAccount += other.MissingAccount + s.InvalidOwner += other.InvalidOwner + s.InvalidData += other.InvalidData + s.MintMismatch += other.MintMismatch + s.NonZeroBalance += other.NonZeroBalance + s.InvalidEthAddress += other.InvalidEthAddress + s.AddressMismatch += other.AddressMismatch + s.WrongCloseAuthority += other.WrongCloseAuthority +} + func (j *ReclaimRentJob) processMint(ctx context.Context, mint solana.PublicKey) error { logger := j.logger.With(zap.String("mint", mint.String())) logger.Info("Processing mint") @@ -156,7 +200,19 @@ func (j *ReclaimRentJob) processMint(ctx context.Context, mint solana.PublicKey) cutoff := time.Now().Add(-reclaimRentLookback) offset := 0 + totalCandidates := 0 + totalClosable := 0 totalClosed := 0 + totalFailed := 0 + var filterStats reclaimRentFilterStats + var firstProcessError error + processErrorCount := 0 + recordProcessError := func(err error) { + processErrorCount++ + if firstProcessError == nil { + firstProcessError = err + } + } for { accounts, err := j.fetchCandidates(ctx, mint.String(), cutoff, reclaimRentDbPageSize, offset) if err != nil { @@ -166,12 +222,16 @@ func (j *ReclaimRentJob) processMint(ctx context.Context, mint solana.PublicKey) break } offset += len(accounts) + totalCandidates += len(accounts) - filtered, err := j.filterOnChain(ctx, accounts) + filtered, pageFilterStats, err := j.filterOnChain(ctx, accounts, mint, authority) if err != nil { logger.Error("filterOnChain failed", zap.Error(err)) + recordProcessError(err) continue } + filterStats.add(pageFilterStats) + totalClosable += len(filtered) for i := 0; i < len(filtered); i += reclaimRentBatchSize { end := i + reclaimRentBatchSize @@ -185,6 +245,18 @@ func (j *ReclaimRentJob) processMint(ctx context.Context, mint solana.PublicKey) zap.Error(err), zap.Int("batch_size", len(batch)), ) + totalFailed += len(batch) + recordProcessError(err) + continue + } + if sig == nil { + err := errors.New("transaction sender returned a nil signature") + logger.Error("processBatch failed", + zap.Error(err), + zap.Int("batch_size", len(batch)), + ) + totalFailed += len(batch) + recordProcessError(err) continue } logger.Info("Reclaimed batch", @@ -194,7 +266,29 @@ func (j *ReclaimRentJob) processMint(ctx context.Context, mint solana.PublicKey) totalClosed += len(batch) } } - logger.Info("Done processing mint", zap.Int("total_closed", totalClosed)) + logger.Info("Done processing mint", + zap.Int("db_candidates", totalCandidates), + zap.Int("onchain_closable", totalClosable), + zap.Int("total_closed", totalClosed), + zap.Int("total_failed", totalFailed), + zap.Int("processing_errors", processErrorCount), + zap.Int("skipped_missing", filterStats.MissingAccount), + zap.Int("skipped_invalid_owner", filterStats.InvalidOwner), + zap.Int("skipped_invalid_data", filterStats.InvalidData), + zap.Int("skipped_mint_mismatch", filterStats.MintMismatch), + zap.Int("skipped_nonzero_balance", filterStats.NonZeroBalance), + zap.Int("skipped_invalid_eth_address", filterStats.InvalidEthAddress), + zap.Int("skipped_address_mismatch", filterStats.AddressMismatch), + zap.Int("skipped_wrong_close_authority", filterStats.WrongCloseAuthority), + ) + if firstProcessError != nil { + return fmt.Errorf( + "%d processing operation(s) failed (%d close attempts affected): %w", + processErrorCount, + totalFailed, + firstProcessError, + ) + } return nil } @@ -222,33 +316,85 @@ func (j *ReclaimRentJob) fetchCandidates(ctx context.Context, mint string, since return pgx.CollectRows(rows, pgx.RowToStructByName[reclaimRentAccount]) } -func (j *ReclaimRentJob) filterOnChain(ctx context.Context, batch []reclaimRentAccount) ([]reclaimRentAccount, error) { +func (j *ReclaimRentJob) filterOnChain( + ctx context.Context, + batch []reclaimRentAccount, + mint solana.PublicKey, + authority solana.PublicKey, +) ([]reclaimRentAccount, reclaimRentFilterStats, error) { + var stats reclaimRentFilterStats pubkeys := make([]solana.PublicKey, 0, len(batch)) for _, acct := range batch { - pubkeys = append(pubkeys, solana.MustPublicKeyFromBase58(acct.Account)) + pubkey, err := solana.PublicKeyFromBase58(acct.Account) + if err != nil { + return nil, stats, fmt.Errorf("invalid account public key %q: %w", acct.Account, err) + } + pubkeys = append(pubkeys, pubkey) } res, err := j.rpcClient.GetMultipleAccountsWithOpts(ctx, pubkeys, &rpc.GetMultipleAccountsOpts{ Encoding: solana.EncodingBase64, }) if err != nil { - return nil, fmt.Errorf("failed to get accounts: %w", err) + return nil, stats, fmt.Errorf("failed to get accounts: %w", err) + } + if len(res.Value) != len(batch) { + return nil, stats, fmt.Errorf("RPC returned %d accounts for a batch of %d", len(res.Value), len(batch)) } filtered := make([]reclaimRentAccount, 0, len(batch)) for i, info := range res.Value { if info == nil { + stats.MissingAccount++ + continue + } + if !info.Owner.Equals(solana.TokenProgramID) { + stats.InvalidOwner++ + continue + } + if info.Data == nil { + stats.InvalidData++ continue } var ta token.Account if err := bin.NewBorshDecoder(info.Data.GetBinary()).Decode(&ta); err != nil { + stats.InvalidData++ + continue + } + if !ta.Mint.Equals(mint) { + stats.MintMismatch++ continue } if ta.Amount != 0 { + stats.NonZeroBalance++ + continue + } + if !common.IsHexAddress(batch[i].EthereumAddress) { + stats.InvalidEthAddress++ + continue + } + expectedUserBank, err := claimable_tokens.DeriveUserBankAccount( + mint, + common.HexToAddress(batch[i].EthereumAddress), + ) + if err != nil { + return nil, stats, fmt.Errorf("derive user bank for %q: %w", batch[i].Account, err) + } + if !pubkeys[i].Equals(expectedUserBank) { + stats.AddressMismatch++ + continue + } + + closeAuthority := ta.Owner + if ta.CloseAuthority != nil { + closeAuthority = *ta.CloseAuthority + } + if !closeAuthority.Equals(authority) { + stats.WrongCloseAuthority++ continue } filtered = append(filtered, batch[i]) } - return filtered, nil + return filtered, stats, nil } func (j *ReclaimRentJob) processBatch(ctx context.Context, batch []reclaimRentAccount, authority solana.PublicKey) (*solana.Signature, error) { @@ -262,11 +408,19 @@ func (j *ReclaimRentJob) processBatch(ctx context.Context, batch []reclaimRentAc } builder := solana.NewTransactionBuilder().SetFeePayer(payer.PublicKey()) + rentDestination := solana.MustPublicKeyFromBase58(claimable_tokens.DefaultRentDestinationAddress) for _, acct := range batch { + userBank, err := solana.PublicKeyFromBase58(acct.Account) + if err != nil { + return nil, fmt.Errorf("invalid account public key %q: %w", acct.Account, err) + } + if !common.IsHexAddress(acct.EthereumAddress) { + return nil, fmt.Errorf("invalid Ethereum address %q for account %s", acct.EthereumAddress, acct.Account) + } inst := claimable_tokens.NewCloseInstructionBuilder(). - SetUserBank(solana.MustPublicKeyFromBase58(acct.Account)). + SetUserBank(userBank). SetAuthority(authority). - SetDestination(payer.PublicKey()). + SetDestination(rentDestination). SetEthAddress(common.HexToAddress(acct.EthereumAddress)) builder.AddInstruction(inst.Build()) } diff --git a/jobs/reclaim_rent_test.go b/jobs/reclaim_rent_test.go new file mode 100644 index 00000000..a6d52447 --- /dev/null +++ b/jobs/reclaim_rent_test.go @@ -0,0 +1,224 @@ +package jobs + +import ( + "bytes" + "context" + "errors" + "fmt" + "testing" + + "api.audius.co/config" + "api.audius.co/solana/spl/programs/claimable_tokens" + "github.com/ethereum/go-ethereum/common" + 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/pashagolub/pgxmock/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +type fakeReclaimRentRPC struct { + result *rpc.GetMultipleAccountsResult + err error +} + +func (f fakeReclaimRentRPC) GetMultipleAccountsWithOpts( + _ context.Context, + _ []solana.PublicKey, + _ *rpc.GetMultipleAccountsOpts, +) (*rpc.GetMultipleAccountsResult, error) { + return f.result, f.err +} + +type fakeReclaimRentSender struct { + wallet *solana.Wallet + tx *solana.Transaction + err error +} + +func (f *fakeReclaimRentSender) GetFeePayer() (*solana.Wallet, error) { + return f.wallet, nil +} + +func (f *fakeReclaimRentSender) SendTransactionWithRetries( + _ context.Context, + builder *solana.TransactionBuilder, + _ rpc.CommitmentType, + _ rpc.TransactionOpts, +) (*solana.Signature, error) { + if f.err != nil { + return nil, f.err + } + tx, err := builder.Build() + if err != nil { + return nil, err + } + f.tx = tx + sig := solana.Signature{1} + return &sig, nil +} + +func encodeTokenAccount(t *testing.T, account token.Account) *rpc.DataBytesOrJSON { + t.Helper() + var buf bytes.Buffer + require.NoError(t, bin.NewBinEncoder(&buf).Encode(account)) + return rpc.DataBytesOrJSONFromBytes(buf.Bytes()) +} + +func TestReclaimRentFilterOnChain(t *testing.T) { + mint := solana.NewWallet().PublicKey() + otherMint := solana.NewWallet().PublicKey() + authority := solana.NewWallet().PublicKey() + otherAuthority := solana.NewWallet().PublicKey() + + makeInfo := func(programOwner solana.PublicKey, account token.Account) *rpc.Account { + return &rpc.Account{Owner: programOwner, Data: encodeTokenAccount(t, account)} + } + baseAccount := token.Account{ + Mint: mint, + Owner: authority, + State: token.Initialized, + Amount: 0, + } + wrongMintAccount := baseAccount + wrongMintAccount.Mint = otherMint + nonZeroAccount := baseAccount + nonZeroAccount.Amount = 1 + wrongOwnerAccount := baseAccount + wrongOwnerAccount.Owner = otherAuthority + closeAuthorityAccount := wrongOwnerAccount + closeAuthorityAccount.CloseAuthority = &authority + externalCloseAuthorityAccount := baseAccount + externalCloseAuthorityAccount.CloseAuthority = &otherAuthority + + accounts := make([]reclaimRentAccount, 11) + for i := range accounts { + accounts[i].EthereumAddress = fmt.Sprintf("0x%040x", i+1) + userBank, err := claimable_tokens.DeriveUserBankAccount( + mint, + common.HexToAddress(accounts[i].EthereumAddress), + ) + require.NoError(t, err) + accounts[i].Account = userBank.String() + } + accounts[9].EthereumAddress = "not-an-ethereum-address" + accounts[10].Account = solana.NewWallet().PublicKey().String() + + job := &ReclaimRentJob{ + rpcClient: fakeReclaimRentRPC{result: &rpc.GetMultipleAccountsResult{Value: []*rpc.Account{ + nil, + makeInfo(solana.SystemProgramID, baseAccount), + {Owner: solana.TokenProgramID, Data: rpc.DataBytesOrJSONFromBytes([]byte{1, 2, 3})}, + makeInfo(solana.TokenProgramID, wrongMintAccount), + makeInfo(solana.TokenProgramID, nonZeroAccount), + makeInfo(solana.TokenProgramID, wrongOwnerAccount), + makeInfo(solana.TokenProgramID, baseAccount), + makeInfo(solana.TokenProgramID, closeAuthorityAccount), + makeInfo(solana.TokenProgramID, externalCloseAuthorityAccount), + makeInfo(solana.TokenProgramID, baseAccount), + makeInfo(solana.TokenProgramID, baseAccount), + }}}, + logger: zap.NewNop(), + } + + filtered, stats, err := job.filterOnChain(context.Background(), accounts, mint, authority) + require.NoError(t, err) + require.Len(t, filtered, 2) + assert.Equal(t, accounts[6], filtered[0]) + assert.Equal(t, accounts[7], filtered[1]) + assert.Equal(t, reclaimRentFilterStats{ + MissingAccount: 1, + InvalidOwner: 1, + InvalidData: 1, + MintMismatch: 1, + NonZeroBalance: 1, + InvalidEthAddress: 1, + AddressMismatch: 1, + WrongCloseAuthority: 2, + }, stats) +} + +func TestReclaimRentProcessBatchUsesProgramDestination(t *testing.T) { + sender := &fakeReclaimRentSender{wallet: solana.NewWallet()} + job := &ReclaimRentJob{transactionSender: sender} + authority := solana.NewWallet().PublicKey() + account := reclaimRentAccount{ + Account: solana.NewWallet().PublicKey().String(), + EthereumAddress: "0x1234567890123456789012345678901234567890", + } + + _, err := job.processBatch(context.Background(), []reclaimRentAccount{account}, authority) + require.NoError(t, err) + require.NotNil(t, sender.tx) + require.Len(t, sender.tx.Message.Instructions, 1) + + metas, err := sender.tx.Message.Instructions[0].ResolveInstructionAccounts(&sender.tx.Message) + require.NoError(t, err) + require.Len(t, metas, 4) + assert.Equal(t, claimable_tokens.DefaultRentDestinationAddress, metas[2].PublicKey.String()) + assert.NotEqual(t, sender.wallet.PublicKey(), metas[2].PublicKey) +} + +func TestReclaimRentProcessBatchReturnsSenderError(t *testing.T) { + expectedErr := errors.New("send failed") + job := &ReclaimRentJob{transactionSender: &fakeReclaimRentSender{ + wallet: solana.NewWallet(), + err: expectedErr, + }} + + _, err := job.processBatch(context.Background(), []reclaimRentAccount{{ + Account: solana.NewWallet().PublicKey().String(), + EthereumAddress: "0x1234567890123456789012345678901234567890", + }}, solana.NewWallet().PublicKey()) + assert.ErrorIs(t, err, expectedErr) +} + +func TestReclaimRentRunReturnsBatchError(t *testing.T) { + pool, err := pgxmock.NewPool() + require.NoError(t, err) + defer pool.Close() + + mint := solana.NewWallet().PublicKey() + authority, _, err := claimable_tokens.DeriveAuthority(mint) + require.NoError(t, err) + ethAddress := "0x1234567890123456789012345678901234567890" + account, err := claimable_tokens.DeriveUserBankAccount(mint, common.HexToAddress(ethAddress)) + require.NoError(t, err) + + pool.ExpectQuery("SELECT DISTINCT"). + WithArgs(mint.String(), pgxmock.AnyArg(), reclaimRentDbPageSize, 0). + WillReturnRows(pgxmock.NewRows([]string{"account", "ethereum_address"}). + AddRow(account.String(), ethAddress)) + pool.ExpectQuery("SELECT DISTINCT"). + WithArgs(mint.String(), pgxmock.AnyArg(), reclaimRentDbPageSize, 1). + WillReturnRows(pgxmock.NewRows([]string{"account", "ethereum_address"})) + + expectedErr := errors.New("send failed") + wallet := solana.NewWallet() + job := &ReclaimRentJob{ + cfg: config.Config{SolanaConfig: config.SolanaConfig{ + FeePayers: []solana.Wallet{*wallet}, + }}, + pool: pool, + rpcClient: fakeReclaimRentRPC{result: &rpc.GetMultipleAccountsResult{Value: []*rpc.Account{{ + Owner: solana.TokenProgramID, + Data: encodeTokenAccount(t, token.Account{ + Mint: mint, + Owner: authority, + State: token.Initialized, + }), + }}}}, + transactionSender: &fakeReclaimRentSender{wallet: wallet, err: expectedErr}, + mints: []solana.PublicKey{mint}, + logger: zap.NewNop(), + } + + err = job.run(context.Background()) + require.Error(t, err) + assert.ErrorIs(t, err, expectedErr) + assert.Contains(t, err.Error(), "1 processing operation(s) failed") + require.NoError(t, pool.ExpectationsWereMet()) +} diff --git a/solana/spl/programs/claimable_tokens/Close.go b/solana/spl/programs/claimable_tokens/Close.go index 84e6c7e7..74d454c0 100644 --- a/solana/spl/programs/claimable_tokens/Close.go +++ b/solana/spl/programs/claimable_tokens/Close.go @@ -9,6 +9,10 @@ import ( "github.com/gagliardetto/treeout" ) +// DefaultRentDestinationAddress is the only destination accepted by the +// deployed claimable-tokens program's Close instruction. +const DefaultRentDestinationAddress = "2HYDf9XvHRKhquxK1z4ETJ8ywueZcqEazyFZdRfLqGcT" + type Close struct { EthAddress common.Address diff --git a/solana/spl/programs/claimable_tokens/CreateTokenAccount.go b/solana/spl/programs/claimable_tokens/CreateTokenAccount.go index 9aaa2182..c1ebc495 100644 --- a/solana/spl/programs/claimable_tokens/CreateTokenAccount.go +++ b/solana/spl/programs/claimable_tokens/CreateTokenAccount.go @@ -154,7 +154,7 @@ func NewCreateTokenAccountInstruction( if err != nil { return nil, err } - userBank, err := deriveUserBankAccount(mint, ethAddress) + userBank, err := DeriveUserBankAccount(mint, ethAddress) 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 04d4a546..8e0b5980 100644 --- a/solana/spl/programs/claimable_tokens/Transfer.go +++ b/solana/spl/programs/claimable_tokens/Transfer.go @@ -165,7 +165,7 @@ func NewTransferInstruction( payer solana.PublicKey, destination solana.PublicKey, ) (*Transfer, error) { - senderUserBank, err := deriveUserBankAccount(mint, senderEthAddress) + senderUserBank, err := DeriveUserBankAccount(mint, senderEthAddress) 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 afe0f21b..b96103db 100644 --- a/solana/spl/programs/claimable_tokens/accounts.go +++ b/solana/spl/programs/claimable_tokens/accounts.go @@ -19,7 +19,9 @@ 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) { +// DeriveUserBankAccount derives the claimable token account for an Ethereum +// address and SPL mint. +func DeriveUserBankAccount(mint solana.PublicKey, ethAddress common.Address) (solana.PublicKey, error) { ethAddressBytes := ethAddress.Bytes() seed := base58.Encode(ethAddressBytes) authority, _, err := DeriveAuthority(mint) diff --git a/solana/spl/programs/claimable_tokens/accounts_test.go b/solana/spl/programs/claimable_tokens/accounts_test.go index 0e968b7d..e66dfd56 100644 --- a/solana/spl/programs/claimable_tokens/accounts_test.go +++ b/solana/spl/programs/claimable_tokens/accounts_test.go @@ -13,7 +13,7 @@ func TestDeriveUserBankAccount(t *testing.T) { ethAddress := common.HexToAddress("0xa507da823bf0c5dc44a759d0d398b7f52097da19") expectedUserBankAccount := solana.MustPublicKeyFromBase58("9oJLynXRLkWZkTXXExPXVbza5n8CzTZLvtJ1Y3pEJ2Pk") - userBankAccount, err := deriveUserBankAccount(mint, ethAddress) + userBankAccount, err := DeriveUserBankAccount(mint, ethAddress) require.NoError(t, err) require.Equal(t, expectedUserBankAccount.String(), userBankAccount.String()) } diff --git a/solana/spl/programs/claimable_tokens/client.go b/solana/spl/programs/claimable_tokens/client.go index 0a019bdd..59797e73 100644 --- a/solana/spl/programs/claimable_tokens/client.go +++ b/solana/spl/programs/claimable_tokens/client.go @@ -63,7 +63,7 @@ func (cc *ClaimableTokensClient) GetOrCreateUserBank( ethAddress common.Address, mint solana.PublicKey, ) (*solana.PublicKey, error) { - userBank, err := deriveUserBankAccount(mint, ethAddress) + userBank, err := DeriveUserBankAccount(mint, ethAddress) if err != nil { return nil, err }