UNPKG

@chorus-one/solana

Version:

All-in-one toolkit for building staking dApps on Solana network

322 lines (287 loc) 11.5 kB
import { SolanaStaker, StakeAccount } from '@chorus-one/solana' import { LocalSigner } from '@chorus-one/signer-local' import { KeyType } from '@chorus-one/signer' import { Connection, PublicKey, LAMPORTS_PER_SOL, StakeProgram } from '@solana/web3.js' import * as bip39 from 'bip39' import { derivePath, getPublicKey } from 'ed25519-hd-key' import type { Logger } from '@chorus-one/utils' /** * SolanaTestStaker is a utility class to simplify testing Solana staking operations. * It manages: * - Initialization from a consistent mnemonic * - Address derivation * - Account funding * - Staking operations */ export class SolanaTestStaker { private mnemonic: string public hdPath: string public ownerAddress: string public validatorAddress: string public staker: SolanaStaker public localSigner: LocalSigner public connection: Connection public logger: Logger /** * Creates a new instance of SolanaTestStaker * @param params Configuration parameters */ constructor (params: { mnemonic: string; rpcUrl?: string; validatorAddress?: string; logger?: Logger }) { if (!params.mnemonic) { throw new Error('Mnemonic is required') } this.mnemonic = params.mnemonic this.hdPath = "m/44'/501'/0'/0'" this.validatorAddress = params.validatorAddress || '8pPNjm5F2xGUG8q7fFwNLcDmAnMDRamEotiDZbJ5seqo' this.logger = params.logger || { info: (...args: unknown[]) => console.log('[TEST]', ...args), error: (...args: unknown[]) => console.error('[TEST]', ...args) } const rpcUrl = params.rpcUrl || 'https://api.devnet.solana.com' this.connection = new Connection(rpcUrl) this.staker = new SolanaStaker({ rpcUrl }) } /** * Initializes the staker, derives the owner address, and initializes the signer */ async init (): Promise<void> { const seed = bip39.mnemonicToSeedSync(this.mnemonic) const { key } = derivePath(this.hdPath, Buffer.from(seed).toString('hex')) const publicKey = getPublicKey(key, false) this.ownerAddress = new PublicKey(publicKey).toString() this.logger.info(`Owner address: ${this.ownerAddress}`) await this.staker.init() this.localSigner = new LocalSigner({ mnemonic: this.mnemonic, accounts: [{ hdPath: this.hdPath }], keyType: KeyType.ED25519, addressDerivationFn: SolanaStaker.getAddressDerivationFn(), logger: this.logger }) await this.localSigner.init() } /** * Get the current balance of the owner account and log it * @param address The address to check the balance for * @returns The balance in SOL */ async getBalance (address: PublicKey): Promise<number> { const balance = await this.connection.getBalance(address) this.logger.info(`Current balance: ${balance} SOL`) return balance / LAMPORTS_PER_SOL } /** * Request an airdrop of SOL if the balance is below the threshold * This operation is heavily rate-limited on the devnet, only the first request will be successful. * @param address The address to check the balance for * @param minBalance The minimum balance to maintain (default: 0.1 SOL) * @param amount The amount to request (default: 2 SOL) */ async requestAirdropIfNeeded (address: PublicKey, minBalance: number = 0.1, amount: number = 2): Promise<void> { const balance = await this.getBalance(address) if (balance < minBalance) { this.logger.info(`Balance low, requesting airdrop for ${amount} SOL`) try { const signature = await this.connection.requestAirdrop( new PublicKey(this.ownerAddress), amount * LAMPORTS_PER_SOL ) const { blockhash, lastValidBlockHeight } = await this.connection.getLatestBlockhash() await this.connection.confirmTransaction({ signature, blockhash, lastValidBlockHeight }) await this.getBalance(address) } catch (error) { this.logger.error('Error requesting airdrop:', error) } } else { this.logger.info('Account has sufficient balance, skipping airdrop') } } /** * Create and delegate a stake account * @param amount The amount to delegate * @returns The address of the created stake account */ async createAndDelegateStake (amount: string): Promise<string> { const { tx, stakeAccountAddress } = await this.staker.buildStakeTx({ ownerAddress: this.ownerAddress, validatorAddress: this.validatorAddress, amount }) this.logger.info(`Created stake account: ${stakeAccountAddress}`) const { signedTx } = await this.staker.sign({ signer: this.localSigner, signerAddress: this.ownerAddress, tx }) const { txHash } = await this.staker.broadcast({ signedTx }) this.logger.info(`Transaction hash: ${txHash}`) const { status } = await this.staker.getTxStatus({ txHash }) this.logger.info(`Transaction status: ${status}`) return stakeAccountAddress } /** * Undelegate a stake account. The unstake operation will undelegate the whole amount of the stake account. * @param stakeAccountAddress The address of the stake account to undelegate * @returns The status of the transaction */ async undelegateStake (stakeAccountAddress: string): Promise<string> { const { tx } = await this.staker.buildUnstakeTx({ ownerAddress: this.ownerAddress, stakeAccountAddress }) const { signedTx } = await this.staker.sign({ signer: this.localSigner, signerAddress: this.ownerAddress, tx }) const { txHash } = await this.staker.broadcast({ signedTx }) this.logger.info(`Unstake transaction hash: ${txHash}`) const { status } = await this.staker.getTxStatus({ txHash }) this.logger.info(`Unstake transaction status: ${status}`) return status } /** * Undelegate a stake account. The unstake operation will undelegate the whole amount of the stake account. * @param stakeAccountAddress The address of the stake account to undelegate * @returns The status of the transaction */ async undelegatePartialStake (amount: string): Promise<{ statuses: string[]; accounts: StakeAccount[] }> { const { transactions, accounts } = await this.staker.buildPartialUnstakeTx({ ownerAddress: this.ownerAddress, amount }) if (transactions.length === 0) { throw new Error('No transactions to sign') } const statuses: string[] = await Promise.all( transactions.map(async (tx, i) => { const { signedTx } = await this.staker.sign({ signer: this.localSigner, signerAddress: this.ownerAddress, tx }) const { txHash } = await this.staker.broadcast({ signedTx }) this.logger.info(`Unstake transaction hash: ${txHash} for the ${i + 1}th transaction of ${transactions.length}`) const { status } = await this.staker.getTxStatus({ txHash }) this.logger.info(`Unstake transaction status: ${status}`) return status }) ) return { statuses, accounts } } /** * Get all stake accounts for the owner * @param stakeAccount The address of the stake account to get (optional). If `null` is passed, all stake accounts will be returned. * @returns An object containing the stake accounts */ async getStakeAccounts (stakeAccount: string | null): Promise<{ accounts: StakeAccount[] }> { const allStakeAccounts = await this.staker.getStakeAccounts({ ownerAddress: this.ownerAddress, withStates: true }) if (!stakeAccount) { return allStakeAccounts } const stakeAccountInfo = allStakeAccounts.accounts.find((account) => account.address === stakeAccount) if (!stakeAccountInfo) { return { accounts: [] } } return { accounts: [stakeAccountInfo] } } /** * Withdraw from a stake account * @param stakeAccountAddress The address of the stake account to withdraw from * @returns The status of the transaction */ async withdrawStake (stakeAccountAddress: string): Promise<string> { const { tx } = await this.staker.buildWithdrawStakeTx({ ownerAddress: this.ownerAddress, stakeAccountAddress }) const { signedTx } = await this.staker.sign({ signer: this.localSigner, signerAddress: this.ownerAddress, tx }) const { txHash } = await this.staker.broadcast({ signedTx }) this.logger.info(`Withdraw transaction hash: ${txHash}`) const { status } = await this.staker.getTxStatus({ txHash }) this.logger.info(`Withdraw transaction status: ${status}`) return status } /** * Split a stake account into two accounts. The amount used as a parameter is the amount to be deposited in the new account. * * Like any new account, the new account will need to be funded with the rent-exempt amount (taken from the owner's account), so the final balance of the new account will be: * * `the amount passed as a parameter + the rent-exempt amount`. * * The remaining amount will stay in the original account. * @param stakeAccountAddress The address of the stake account to split * @param amount The amount to deposit in the new account * @returns The address of the new stake account and the status of the transaction */ async splitStake ( stakeAccountAddress: string, amount: string ): Promise<{ newStakeAccountAddress: string; status: string }> { const { tx, stakeAccountAddress: newStakeAccountAddress } = await this.staker.buildSplitStakeTx({ ownerAddress: this.ownerAddress, stakeAccountAddress, amount }) this.logger.info(`Created new stake account: ${newStakeAccountAddress}`) const { signedTx } = await this.staker.sign({ signer: this.localSigner, signerAddress: this.ownerAddress, tx }) const { txHash } = await this.staker.broadcast({ signedTx }) this.logger.info(`Split transaction hash: ${txHash}`) const { status } = await this.staker.getTxStatus({ txHash }) this.logger.info(`Split transaction status: ${status}`) return { status, newStakeAccountAddress } } /** * Unstake and withdraw all stake accounts owned by the test wallet */ async cleanupAllStakeAccounts (): Promise<void> { const allStakeAccounts = await this.getStakeAccounts(null) console.log(`Found ${allStakeAccounts.accounts.length} stake accounts.`) for (const account of allStakeAccounts.accounts) { const { address, state } = account if (state === 'delegated') { this.logger.info(`Unstaking account ${address}`) try { await this.undelegateStake(address) } catch (err) { this.logger.error(`Failed to undelegate ${address}:`, err) } } if (state === 'undelegated') { this.logger.info(`Withdrawing from account ${address}`) try { await this.withdrawStake(address) } catch (err) { this.logger.error(`Failed to withdraw from ${address}:`, err) } } } // wait for 2 seconds to ensure all transactions are processed await new Promise((resolve) => setTimeout(resolve, 2000)) } /** * Get the minimum stake rent exemption amount * @returns The minimum rent exemption amount in lamports */ async getMinimumStakeRentExemption (): Promise<number> { const rentExemption = await this.connection.getMinimumBalanceForRentExemption(StakeProgram.space) return rentExemption } }