@chorus-one/solana
Version:
All-in-one toolkit for building staking dApps on Solana network
322 lines (287 loc) • 11.5 kB
text/typescript
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
}
}