@chorus-one/solana
Version:
All-in-one toolkit for building staking dApps on Solana network
539 lines (538 loc) • 25.6 kB
JavaScript
import { Connection, Lockup, PublicKey, Keypair, StakeProgram, Authorized, VersionedTransaction, TransactionMessage } from '@solana/web3.js';
import { getDenomMultiplier, macroToDenomAmount, denomToMacroAmount, getTrackingInstruction } from './tx';
import { DEFAULT_TRACKING_REF_CODE } from '@chorus-one/utils';
/**
* This class provides the functionality to stake, unstake, and withdraw for Solana blockchains.
*
* It also provides the ability to retrieve staking information and rewards for an account.
*/
export class SolanaStaker {
networkConfig;
commitment;
connection;
/**
* This **static** method is used to derive an address from a public key.
*
* It can be used for signer initialization, e.g. `FireblocksSigner` or `LocalSigner`.
*
* @returns Returns an array containing the derived address.
*/
static getAddressDerivationFn = () => async (publicKey, _derivationPath) => {
const pk = new PublicKey(publicKey);
return [pk.toBase58()];
};
/**
* Creates a SolanaStaker instance.
*
* @param params - Initialization configuration
* @param params.rpcUrl - The URL of the SOLANA network RPC endpoint
* @param params.commitment - (Optional) The level of commitment desired when querying the blockchain. Default is 'confirmed'.
*
* @returns An instance of SolanaStaker.
*/
constructor(params) {
const { ...networkConfig } = params;
this.networkConfig = networkConfig;
this.commitment = networkConfig.commitment || 'confirmed';
}
/**
* Initializes the SolanaStaker instance and connects to the blockchain.
*
* @returns A promise which resolves once the SolanaStaker instance has been initialized.
*/
async init() {
this.connection = new Connection(this.networkConfig.rpcUrl, this.commitment);
}
/**
* Builds a new stake account transaction.
*
* @param params - Parameters for building the transaction
* @param params.ownerAddress - The stake account owner's address
* @param params.amount - The amount to stake, specified in `SOL`
*
* @returns Returns a promise that resolves to new stake account transaction.
*/
async buildCreateStakeAccountTx(params) {
const connection = this.getConnection();
const { ownerAddress, amount } = params;
const amountInLamports = macroToDenomAmount(amount, getDenomMultiplier());
const mininmumStakeAmount = await connection.getMinimumBalanceForRentExemption(StakeProgram.space);
if (amountInLamports < mininmumStakeAmount) {
throw new Error(`Amount must be greater than ${mininmumStakeAmount / Number(getDenomMultiplier())}`);
}
// stake account owner
const ownerPublicKey = new PublicKey(ownerAddress);
// randomly generated stake account
const stakeAccount = Keypair.generate();
const tx = StakeProgram.createAccount({
fromPubkey: ownerPublicKey,
stakePubkey: stakeAccount.publicKey,
authorized: new Authorized(ownerPublicKey, ownerPublicKey),
lamports: amountInLamports,
lockup: new Lockup(0, 0, ownerPublicKey)
});
return {
tx: { tx, additionalKeys: [stakeAccount] },
stakeAccountAddress: stakeAccount.publicKey.toBase58()
};
}
/**
* Builds a staking transaction.
*
* @param params - Parameters for building the transaction
* @param params.ownerAddress - The stake account owner's address
* @param params.validatorAddress - The validatiors vote account address to delegate the stake to
* @param params.stakeAccountAddress - The stake account address to delegate from. If not provided, a new stake account will be created.
* @param params.amount - The amount to stake, specified in `SOL`. If `stakeAccountAddress` is not provided, this parameter is required.
* @param params.referrer - (Optional) A custom tracking reference. If not provided, the default tracking reference will be used.
*
* @returns Returns a promise that resolves to a SOLANA staking transaction.
*/
async buildStakeTx(params) {
const { ownerAddress, stakeAccountAddress, validatorAddress, amount, referrer = DEFAULT_TRACKING_REF_CODE } = params;
let stakeAccountAddr;
let createAccountTx;
if (stakeAccountAddress === undefined) {
if (amount === undefined) {
throw new Error('with stakeAccountAddress not being present, amount must be defined');
}
const createStakeAccountTx = await this.buildCreateStakeAccountTx({
ownerAddress,
amount: amount
});
stakeAccountAddr = createStakeAccountTx.stakeAccountAddress;
createAccountTx = createStakeAccountTx.tx;
}
else {
// ensure the stake account exists
const data = await this.getStakeAccounts({ ownerAddress });
if (!data.accounts.some((account) => account.address === stakeAccountAddress)) {
throw new Error(`Stake account ${stakeAccountAddress} not found for owner ${ownerAddress}`);
}
stakeAccountAddr = stakeAccountAddress;
}
const delegateTx = StakeProgram.delegate({
stakePubkey: new PublicKey(stakeAccountAddr),
authorizedPubkey: new PublicKey(ownerAddress),
votePubkey: new PublicKey(validatorAddress)
});
const trackingInstruction = getTrackingInstruction(referrer);
delegateTx.instructions.push(trackingInstruction);
const delegateSolanaTx = {
tx: delegateTx,
additionalKeys: []
};
// combine createStakeAccountTx with delegateStakeTx transactions
const finalTx = createAccountTx !== undefined ? combineTransactions(createAccountTx, delegateSolanaTx) : delegateSolanaTx;
return { tx: finalTx, stakeAccountAddress: stakeAccountAddr };
}
/**
* Builds an unstaking transaction.
*
* @param params - Parameters for building the transaction
* @param params.ownerAddress - The stake account owner's address
* @param params.stakeAccountAddress - The stake account address to deactivate
* @param params.referrer - (Optional) A custom tracking reference. If not provided, the default tracking reference will be used.
*
* @returns Returns a promise that resolves to a SOLANA unstaking transaction.
*/
async buildUnstakeTx(params) {
const { ownerAddress, stakeAccountAddress, referrer = DEFAULT_TRACKING_REF_CODE } = params;
const stakePubkey = new PublicKey(stakeAccountAddress);
const stakeState = await this.getStakeAccounts({ ownerAddress, withStates: true });
const foundStakeAccount = stakeState.accounts.find((account) => account.address === stakeAccountAddress);
if (foundStakeAccount === undefined) {
throw new Error(`stake account ${stakeAccountAddress} not found for owner ${ownerAddress}`);
}
if (foundStakeAccount.state !== 'delegated') {
throw new Error(`stake account ${stakeAccountAddress} is not delegated, current status: ${foundStakeAccount.state}`);
}
const deactivateTx = StakeProgram.deactivate({
stakePubkey,
authorizedPubkey: new PublicKey(ownerAddress)
});
const trackingInstruction = getTrackingInstruction(referrer);
deactivateTx.instructions.push(trackingInstruction);
return { tx: { tx: deactivateTx } };
}
/**
* Builds a partial unstake transaction.
*
* This method allows for unstaking a specific amount from multiple stake accounts.
* It will split the stake accounts if necessary to achieve the desired unstake amount.
*
* @param params - Parameters for building the transaction
* @param params.ownerAddress - The stake account owner's address
* @param params.amount - The amount to unstake, specified in `SOL`
* @param params.referrer - (Optional) A custom tracking reference. If not provided, the default tracking reference will be used.
*
* @returns Returns a promise that resolves to an array of SOLANA transactions for partial unstaking and the affected stake accounts.
*/
async buildPartialUnstakeTx(params) {
const { ownerAddress, amount, referrer = DEFAULT_TRACKING_REF_CODE } = params;
const allStakeAccounts = await this.getStakeAccounts({ ownerAddress, withStates: true });
let delegatedStakeAccounts = allStakeAccounts.accounts.filter((account) => account.state === 'delegated');
if (delegatedStakeAccounts.length === 0) {
throw new Error(`No delegated stake account found for owner ${ownerAddress}`);
}
const totalStakedLamports = delegatedStakeAccounts.reduce((acc, cur) => acc + cur.amount, 0);
const amountToUnstakeLamports = macroToDenomAmount(amount, getDenomMultiplier());
if (amountToUnstakeLamports > totalStakedLamports) {
throw new Error(`Requested ${amountToUnstakeLamports} lamports exceeds total staked: ${totalStakedLamports}`);
}
delegatedStakeAccounts.sort((a, b) => a.amount - b.amount);
let remainingAmount = amountToUnstakeLamports;
const transactions = [];
const accounts = [];
const connection = this.getConnection();
const rentExemption = await connection.getMinimumBalanceForRentExemption(StakeProgram.space);
while (remainingAmount > 0) {
// Exact match - full unstake
const maybeFullUnstake = delegatedStakeAccounts.find((a) => a.amount === remainingAmount);
if (maybeFullUnstake) {
const { tx } = await this.buildUnstakeTx({
ownerAddress,
stakeAccountAddress: maybeFullUnstake.address,
referrer
});
transactions.push(tx);
accounts.push(maybeFullUnstake);
break;
}
// Try to split safely
const maybeSplit = delegatedStakeAccounts.find((a) => {
return a.amount >= remainingAmount && a.amount - remainingAmount >= rentExemption;
});
if (maybeSplit) {
const newStakeAccount = new Keypair();
const splitTx = StakeProgram.split({
stakePubkey: new PublicKey(maybeSplit.address),
authorizedPubkey: new PublicKey(ownerAddress),
splitStakePubkey: newStakeAccount.publicKey,
lamports: remainingAmount
}, rentExemption);
const deactivateTx = StakeProgram.deactivate({
stakePubkey: newStakeAccount.publicKey,
authorizedPubkey: new PublicKey(ownerAddress)
});
const tx = splitTx.add(deactivateTx);
const trackingInstruction = getTrackingInstruction(referrer);
tx.instructions.push(trackingInstruction);
transactions.push({ tx, additionalKeys: [newStakeAccount] });
accounts.push(maybeSplit);
break;
}
if (delegatedStakeAccounts.length === 0) {
throw new Error(`Ran out of stake accounts before satisfying unstake amount. Remaining: ${remainingAmount}`);
}
// Fallback: consume the largest fully — but only if it doesn’t exceed the remaining amount -
// we don't want to unstake more than requested
const largest = delegatedStakeAccounts[delegatedStakeAccounts.length - 1];
console.log(`Unstaking from largest account: ${largest.address} with amount: ${largest.amount}. Remaining: ${remainingAmount}`);
if (largest.amount < remainingAmount) {
throw new Error(`Unable to unstake ${remainingAmount} lamports without exceeding the requested amount. ` +
`The only available account (${largest.address}) holds ${largest.amount} lamports.`);
}
const { tx } = await this.buildUnstakeTx({
ownerAddress,
stakeAccountAddress: largest.address,
referrer
});
transactions.push(tx);
accounts.push(largest);
remainingAmount -= largest.amount;
delegatedStakeAccounts = delegatedStakeAccounts.filter((a) => a.address !== largest.address);
}
return { transactions, accounts };
}
/**
* Builds a withdraw stake transaction.
*
* @param params - Parameters for building the transaction
* @param params.ownerAddress - The stake account owner's address
* @param params.stakeAccountAddress - The stake account address to withdraw funds from
* @param params.amount - The amount to withdraw, specified in `SOL`. If not provided, the entire stake amount will be withdrawn.
*
* @returns Returns a promise that resolves to a SOLANA withdraw stake transaction.
*/
async buildWithdrawStakeTx(params) {
const connection = this.getConnection();
const { ownerAddress, stakeAccountAddress, amount } = params;
const stakeBalance = amount === undefined || amount == '0'
? await connection.getBalance(new PublicKey(stakeAccountAddress))
: macroToDenomAmount(amount, getDenomMultiplier());
const withdrawTx = StakeProgram.withdraw({
stakePubkey: new PublicKey(stakeAccountAddress),
authorizedPubkey: new PublicKey(ownerAddress),
toPubkey: new PublicKey(ownerAddress),
lamports: stakeBalance
});
return { tx: { tx: withdrawTx } };
}
/**
* Builds a merge stake transaction.
*
* Please note there are conditions for merging stake accounts:
* https://docs.solana.com/staking/stake-accounts#merging-stake-accounts
*
* @param params - Parameters for building the transaction
* @param params.ownerAddress - The stake account owner's address
* @param params.sourceAddress - The stake account address to merge funds from
* @param params.destinationAddress - The stake account address to merge funds to
*
* @returns Returns a promise that resolves to a SOLANA merge stake transaction.
*/
async buildMergeStakesTx(params) {
const { ownerAddress, sourceAddress, destinationAddress } = params;
const mergeTx = StakeProgram.merge({
sourceStakePubKey: new PublicKey(sourceAddress),
stakePubkey: new PublicKey(destinationAddress),
authorizedPubkey: new PublicKey(ownerAddress)
});
return { tx: { tx: mergeTx } };
}
/**
* Builds a split stake transaction.
*
* @param params - Parameters for building the transaction
* @param params.ownerAddress - The stake account owner's address
* @param params.stakeAccountAddress - The stake account address to split funds from
* @param params.amount - The amount to transfer from stakeAccountAddress to new staking account, specified in `SOL`
*
* @returns Returns a promise that resolves to a SOLANA split stake transaction.
*/
async buildSplitStakeTx(params) {
const connection = this.getConnection();
const { ownerAddress, stakeAccountAddress, amount } = params;
const amountInLamports = macroToDenomAmount(amount, getDenomMultiplier());
const minimumStakeAmount = await connection.getMinimumBalanceForRentExemption(StakeProgram.space);
const newStakeAccount = Keypair.generate();
const splitTx = StakeProgram.split({
stakePubkey: new PublicKey(stakeAccountAddress),
authorizedPubkey: new PublicKey(ownerAddress),
splitStakePubkey: newStakeAccount.publicKey,
lamports: amountInLamports
}, minimumStakeAmount);
return {
tx: { tx: splitTx, additionalKeys: [newStakeAccount] },
stakeAccountAddress: newStakeAccount.publicKey.toBase58()
};
}
/**
* Retrieves the staking information for a specified delegator.
*
* @param params - Parameters for the request
* @param params.ownerAddress - The stake account owner's address
* @param params.validatorAddress - (Optional) The validator address to gather staking information from
* @param params.state - (Optional) The stake account state to filter by (default: 'delegated')
*
* @returns Returns a promise that resolves to the staking information for the specified delegator.
*/
async getStake(params) {
const { ownerAddress, validatorAddress, state } = params;
const stakeAccountState = state || 'delegated';
const stakeAccounts = await this.getStakeAccounts({
ownerAddress,
validatorAddress,
withStates: true,
withMacroDenom: true
});
const total = stakeAccounts.accounts
.filter((account) => {
if (stakeAccountState === 'all') {
return true;
}
return account.state === stakeAccountState;
})
.map((account) => account.amount)
.reduce((acc, cur) => acc + cur, 0);
return { balance: total.toString() };
}
/**
* Signs a transaction using the provided signer.
*
* @param params - Parameters for the signing process
* @param params.signer - A signer instance.
* @param params.signerAddress - The address of the signer
* @param params.tx - The transaction to sign
*
* @returns A promise that resolves to an object containing the signed transaction.
*/
async sign(params) {
const connection = this.getConnection();
const { signer, signerAddress, tx } = params;
const { blockhash } = await connection.getLatestBlockhash();
const versionedTransaction = new VersionedTransaction(new TransactionMessage({
payerKey: new PublicKey(signerAddress),
recentBlockhash: blockhash,
instructions: tx.tx.instructions
}).compileToV0Message());
const serializedMessage = versionedTransaction.message.serialize();
let message = '';
if (Buffer.isBuffer(serializedMessage)) {
message = serializedMessage.toString('hex');
}
else {
message = Buffer.from(serializedMessage).toString('hex');
}
const keys = tx.additionalKeys || [];
if (keys.length > 0) {
versionedTransaction.sign(keys);
}
const signingData = { tx };
const { sig, pk } = await signer.sign(signerAddress, { message, data: signingData }, { note: '' });
const signatureBytes = Uint8Array.from(Buffer.from(sig.fullSig, 'hex'));
versionedTransaction.addSignature(new PublicKey(pk), signatureBytes);
return { signedTx: versionedTransaction };
}
/**
* Broadcasts a signed transaction to the network.
*
* @param params - Parameters for the broadcast process
* @param params.signedTx - The signed transaction to broadcast
*
* @returns A promise that resolves to the final execution outcome of the broadcast transaction.
*
*/
async broadcast(params) {
const connection = this.getConnection();
const { signedTx } = params;
if (signedTx.signatures.length == 0) {
throw new Error('the provided transaction is not signed');
}
const signature = await connection.sendRawTransaction(signedTx.serialize());
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash(this.commitment);
const confirmation = await connection.confirmTransaction({
signature,
blockhash: blockhash,
lastValidBlockHeight: lastValidBlockHeight
}, this.commitment);
return {
txHash: signature,
slot: confirmation.context.slot,
error: confirmation.value.err
};
}
/**
* Retrieves the status of a transaction using the transaction hash.
*
* @param params - Parameters for the transaction status request
* @param params.txHash - The transaction hash to query
*
* @returns A promise that resolves to an object containing the transaction status.
*/
async getTxStatus(params) {
const connection = this.getConnection();
const { txHash } = params;
const txConfig = {
commitment: this.commitment == 'confirmed' ? 'confirmed' : 'finalized',
maxSupportedTransactionVersion: 0
};
const tx = await connection.getTransaction(txHash, txConfig);
if (tx === null) {
return { status: 'unknown', receipt: null };
}
if (tx.meta === null || tx.meta === undefined) {
return { status: 'unknown', receipt: tx };
}
if (tx.meta?.err !== null) {
return { status: 'failure', receipt: tx };
}
return { status: 'success', receipt: tx };
}
/**
* Retrieves the stake accounts associated with an owner address.
*
* @param params - Parameters for the broadcast process
* @param params.ownerAddress - The stake account owner's address
* @param params.validatorAddress - (Optional) The validator address to filter the stake accounts by
* @param params.withStates - (Optional) If true, the state of the stake account will be included in the response
* @param params.withMacroDenom - (Optional) If true, the stake account balance will be returned in `SOL` denomination
*
* @returns A promise that resolves to stake account list.
*/
async getStakeAccounts(params) {
const connection = this.getConnection();
const { ownerAddress, validatorAddress, withStates, withMacroDenom } = params;
const filters = [
{
memcmp: {
offset: 44,
bytes: ownerAddress
}
}
];
if (validatorAddress !== undefined) {
filters.push({
memcmp: {
offset: 124,
bytes: validatorAddress
}
});
}
const currentStakeAccounts = await connection.getParsedProgramAccounts(StakeProgram.programId, {
commitment: this.commitment,
filters
});
const currentEpoch = (await connection.getEpochInfo()).epoch;
const accounts = currentStakeAccounts.map((account) => {
let state = 'undelegated';
let stakedTo = validatorAddress;
if (withStates) {
if (Buffer.isBuffer(account.account.data)) {
throw new Error('account data is not parsed');
}
// reference:
// https://github.com/solana-labs/solana/blob/27eff8408b7223bb3c4ab70523f8a8dca3ca6645/account-decoder/src/parse_stake.rs#L33
const parsed = account.account.data.parsed;
if (parsed['type'] === 'delegated') {
const delegation = parsed['info']['stake']['delegation'];
state = 'delegated';
if (
// 2^64 - 1 = 18446744073709551615 (max value for uint64)
BigInt(delegation['deactivationEpoch']) < BigInt('18446744073709551615')) {
state = 'deactivating';
// solana doesn't cleanup the delegation info even though the stake is deactivated
if (BigInt(delegation['deactivationEpoch']) < BigInt(currentEpoch)) {
state = 'undelegated';
}
else {
if (BigInt(delegation['deactivationEpoch']) == BigInt(delegation['activationEpoch'])) {
state = 'deactivating';
}
}
}
}
if (validatorAddress === undefined && ['delegated', 'deactivating'].includes(state)) {
stakedTo = parsed['info']['stake']['delegation']['voter'];
}
}
return {
address: account.pubkey.toBase58(),
amount: withMacroDenom
? denomToMacroAmount(account.account.lamports.toString(), getDenomMultiplier())
: account.account.lamports,
state,
validatorAddress: stakedTo
};
});
return { accounts };
}
getConnection() {
if (this.connection === undefined) {
throw new Error('SolanaStaker not initialized. Did you forget to call init()?');
}
return this.connection;
}
}
function combineTransactions(tx1, tx2) {
tx1.tx.instructions.push(...tx2.tx.instructions);
tx1.tx.signatures.push(...tx2.tx.signatures);
if (tx2.additionalKeys !== undefined) {
if (tx1.additionalKeys === undefined) {
tx1.additionalKeys = [];
}
tx1.additionalKeys.push(...tx2.additionalKeys);
}
return tx1;
}