[stake-pool] js: web3 stake pool bindings (#2604)

* - add stake-pool-web3-bindings (preview)

* - update readme

* - add tests

* - remove package-lock from foreign project

* - revert changes from foreign project

* - review improvenments

* - refactor regarding review suggestions

* - fix test
- fix readme

* Update stake-pool/js/src/instructions.ts

Co-authored-by: Jon Cinque <jon.cinque@gmail.com>

* - add withdraw authority as optional parameter

Co-authored-by: Jon Cinque <jon.cinque@gmail.com>
This commit is contained in:
Alexander Ray 2021-12-23 19:34:02 +01:00 committed by GitHub
parent 1f4b65153f
commit cd8d79a2b4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 6013 additions and 3545 deletions

View File

@ -1 +1,3 @@
dist dist
.idea
node_modules

View File

@ -0,0 +1,8 @@
{
"arrowParens": "avoid",
"bracketSpacing":false,
"singleQuote": true,
"semi": false,
"tabWidth": 2,
"trailingComma": "all"
}

View File

@ -1,7 +0,0 @@
arrowParens: "avoid"
bracketSpacing: false
jsxBracketSameLine: false
semi: true
singleQuote: true
tabWidth: 2
trailingComma: "all"

View File

@ -29,19 +29,5 @@ Sample output:
``` ```
> stake-pool-js@0.0.1 test > stake-pool-js@0.0.1 test
> ./node_modules/mocha/bin/mocha -p ./dist ```
schema.decode
StakePoolAccount
✓ should successfully decode StakePoolAccount account data
ValidatorListAccount
✓ should successfully decode ValidatorListAccount account data
✓ should successfully decode ValidatorListAccount with nonempty ValidatorInfo
index.ts/PrettyPrintPubkey
✓ should successfully pretty print a pubkey
4 passing (610ms)
```

View File

@ -0,0 +1,7 @@
// it's needed for jest - https://jestjs.io/docs/getting-started#using-typescript
module.exports = {
presets: [
['@babel/preset-env', {targets: {node: 'current'}}],
'@babel/preset-typescript',
],
};

File diff suppressed because it is too large Load Diff

View File

@ -1,27 +1,37 @@
{ {
"name": "@solana/spl-stake-pool", "name": "@solana/spl-stake-pool",
"version": "0.1.0", "version": "0.2.1",
"description": "SPL Stake Pool Program JS API", "description": "SPL Stake Pool Program JS API",
"scripts": { "scripts": {
"lint": "npx prettier src/*.ts -w", "lint": "npx prettier src/*.ts -w",
"build": "tsc", "test": "jest",
"test": "./node_modules/mocha/bin/mocha -p ./dist" "test:watch": "jest --watch",
"test:cov": "jest --coverage"
}, },
"keywords": [], "keywords": [],
"author": "Lieu Zheng Hong", "authors": [
"Lieu Zheng Hong",
"mFactory Team (https://mfactory.ch)"
],
"license": "ISC", "license": "ISC",
"devDependencies": {
"@types/mocha": "^8.2.2",
"mocha": "^8.4.0",
"prettier": "^2.2.1",
"typescript": "^4.2.4"
},
"dependencies": { "dependencies": {
"@solana/web3.js": "^1.18.0", "@project-serum/borsh": "^0.2.2",
"assert": "^2.0.0", "@solana/spl-token": "^0.1.8",
"borsh": "^0.4.0", "@solana/web3.js": "^1.30.2",
"buffer": "^6.0.1", "bn.js": "^5.2.0",
"buffer": "^6.0.3",
"process": "^0.11.10" "process": "^0.11.10"
}, },
"type": "module" "devDependencies": {
"@babel/preset-env": "^7.16.5",
"@babel/preset-typescript": "^7.16.5",
"@types/bn.js": "^5.1.0",
"@types/jest": "^27.0.3",
"jest": "^27.3.1",
"prettier": "^2.2.1"
},
"jest": {
"testRegex": ".*\\.test\\.ts$",
"testEnvironment": "node"
}
} }

View File

@ -0,0 +1,9 @@
import { Buffer } from "buffer";
import { PublicKey } from "@solana/web3.js";
import { solToLamports } from "./utils";
export const TRANSIENT_STAKE_SEED_PREFIX = Buffer.from('transient');
export const STAKE_POOL_PROGRAM_ID = new PublicKey('SPoo1Ku8WFXoNDMHPsrGSTSG1Y47rzgn41SLUNakuHy');
export const MIN_STAKE_BALANCE = solToLamports(0.001);

View File

@ -0,0 +1,3 @@
that files are copied from https://github.com/solana-labs/solana-web3.js
I think it would be a good idea to export that functionality

View File

@ -0,0 +1,48 @@
import { Buffer } from 'buffer';
import * as BufferLayout from '@solana/buffer-layout';
import * as Layout from './layout';
/**
* @internal
*/
export type InstructionType = {
/** The Instruction index (from solana upstream program) */
index: number;
/** The BufferLayout to use to build data */
layout: BufferLayout.Layout;
};
/**
* Populate a buffer of instruction data using an InstructionType
* @internal
*/
export function encodeData(type: InstructionType, fields?: any): Buffer {
const allocLength = type.layout.span >= 0 ? type.layout.span : Layout.getAlloc(type, fields);
const data = Buffer.alloc(allocLength);
const layoutFields = Object.assign({ instruction: type.index }, fields);
type.layout.encode(layoutFields, data);
return data;
}
/**
* Decode instruction data buffer using an InstructionType
* @internal
*/
export function decodeData(type: InstructionType, buffer: Buffer): any {
let data;
try {
data = type.layout.decode(buffer);
} catch (err) {
throw new Error('invalid instruction; ' + err);
}
if (data.instruction !== type.index) {
throw new Error(
`invalid instruction; instruction index mismatch ${data.instruction} != ${type.index}`,
);
}
return data;
}

View File

@ -0,0 +1,92 @@
import {Buffer} from 'buffer';
import * as BufferLayout from '@solana/buffer-layout';
/**
* Layout for a public key
*/
export const publicKey = (
property: string = 'publicKey',
): BufferLayout.Layout => {
return BufferLayout.blob(32, property);
};
/**
* Layout for a 64bit unsigned value
*/
export const uint64 = (property: string = 'uint64'): BufferLayout.Layout => {
return BufferLayout.blob(8, property);
};
/**
* Layout for a Rust String type
*/
export const rustString = (property: string = 'string') => {
const rsl = BufferLayout.struct(
[
BufferLayout.u32('length'),
BufferLayout.u32('lengthPadding'),
BufferLayout.blob(BufferLayout.offset(BufferLayout.u32(), -8), 'chars'),
],
property,
);
const _decode = rsl.decode.bind(rsl);
const _encode = rsl.encode.bind(rsl);
rsl.decode = (buffer: any, offset: any) => {
const data = _decode(buffer, offset);
return data['chars'].toString('utf8');
};
rsl.encode = (str: any, buffer: any, offset: any) => {
const data = {
chars: Buffer.from(str, 'utf8'),
};
return _encode(data, buffer, offset);
};
(rsl as any).alloc = (str: any) => {
return (
BufferLayout.u32().span +
BufferLayout.u32().span +
Buffer.from(str, 'utf8').length
);
};
return rsl;
};
/**
* Layout for an Authorized object
*/
export const authorized = (property: string = 'authorized') => {
return BufferLayout.struct(
[publicKey('staker'), publicKey('withdrawer')],
property,
);
};
/**
* Layout for a Lockup object
*/
export const lockup = (property: string = 'lockup') => {
return BufferLayout.struct(
[
BufferLayout.ns64('unixTimestamp'),
BufferLayout.ns64('epoch'),
publicKey('custodian'),
],
property,
);
};
export function getAlloc(type: any, fields: any): number {
let alloc = 0;
type.layout.fields.forEach((item: any) => {
if (item.span >= 0) {
alloc += item.span;
} else if (typeof item.alloc === 'function') {
alloc += item.alloc(fields[item.property]);
}
});
return alloc;
}

View File

@ -1,24 +1,59 @@
import * as schema from './schema.js'; import {
import solanaWeb3 from '@solana/web3.js'; AccountInfo,
import assert from 'assert'; Connection,
Keypair,
PublicKey,
Signer, StakeProgram,
SystemProgram,
TransactionInstruction,
} from '@solana/web3.js';
import {
ASSOCIATED_TOKEN_PROGRAM_ID,
TOKEN_PROGRAM_ID,
Token,
} from '@solana/spl-token';
import {
addAssociatedTokenAccount,
calcLamportsWithdrawAmount,
findStakeProgramAddress,
findWithdrawAuthorityProgramAddress,
getTokenAccount,
newStakeAccount,
prepareWithdrawAccounts,
lamportsToSol,
solToLamports,
} from './utils';
import { StakePoolInstruction } from './instructions';
import { StakePoolLayout, ValidatorListLayout, ValidatorList, StakePool } from './layouts';
import { MIN_STAKE_BALANCE, STAKE_POOL_PROGRAM_ID } from "./constants";
export class StakePoolAccounts { export type { StakePool, AccountType, ValidatorList, ValidatorStakeInfo } from './layouts';
/** export { STAKE_POOL_PROGRAM_ID } from './constants';
* Wrapper class for a stake pool. export * from './instructions';
* Each stake pool has a stake pool account and a validator list account.
*/ export interface ValidatorListAccount {
stakePool: StakePoolAccount; pubkey: PublicKey;
validatorList: ValidatorListAccount; account: AccountInfo<ValidatorList>;
} }
export interface StakePoolAccount { export interface StakePoolAccount {
pubkey: solanaWeb3.PublicKey; pubkey: PublicKey;
account: solanaWeb3.AccountInfo<schema.StakePool>; account: AccountInfo<StakePool>;
} }
export interface ValidatorListAccount { export interface WithdrawAccount {
pubkey: solanaWeb3.PublicKey; stakeAddress: PublicKey;
account: solanaWeb3.AccountInfo<schema.ValidatorList>; voteAddress?: PublicKey;
poolAmount: number;
}
/**
* Wrapper class for a stake pool.
* Each stake pool has a stake pool account and a validator list account.
*/
export interface StakePoolAccounts {
stakePool: StakePoolAccount | undefined;
validatorList: ValidatorListAccount | undefined;
} }
/** /**
@ -27,15 +62,19 @@ export interface ValidatorListAccount {
* @param stakePoolPubKey: The public key (address) of the stake pool account. * @param stakePoolPubKey: The public key (address) of the stake pool account.
*/ */
export async function getStakePoolAccount( export async function getStakePoolAccount(
connection: solanaWeb3.Connection, connection: Connection,
stakePoolPubKey: solanaWeb3.PublicKey, stakePoolPubKey: PublicKey,
): Promise<StakePoolAccount> { ): Promise<StakePoolAccount> {
const account = await connection.getAccountInfo(stakePoolPubKey); const account = await connection.getAccountInfo(stakePoolPubKey);
if (!account) {
throw new Error('Invalid account');
}
return { return {
pubkey: stakePoolPubKey, pubkey: stakePoolPubKey,
account: { account: {
data: schema.StakePool.decode(account.data), data: StakePoolLayout.decode(account.data),
executable: account.executable, executable: account.executable,
lamports: account.lamports, lamports: account.lamports,
owner: account.owner, owner: account.owner,
@ -43,114 +82,393 @@ export async function getStakePoolAccount(
}; };
} }
/**
* Retrieves and deserializes a ValidatorList account using a web3js connection and the validator list address.
* @param connection: An active web3js connection.
* @param validatorListPubKey: The public key (address) of the validator list account.
*/
export async function getValidatorListAccount(
connection: solanaWeb3.Connection,
validatorListPubKey: solanaWeb3.PublicKey,
): Promise<ValidatorListAccount> {
try {
const account = await connection.getAccountInfo(validatorListPubKey);
return {
pubkey: validatorListPubKey,
account: {
data: schema.ValidatorList.decodeUnchecked(account.data),
executable: account.executable,
lamports: account.lamports,
owner: account.owner,
},
};
} catch (error) {
console.log(error);
}
}
/** /**
* Retrieves all StakePool and ValidatorList accounts that are running a particular StakePool program. * Retrieves all StakePool and ValidatorList accounts that are running a particular StakePool program.
* @param connection: An active web3js connection. * @param connection: An active web3js connection.
* @param stakePoolProgramAddress: The public key (address) of the StakePool program. * @param stakePoolProgramAddress: The public key (address) of the StakePool program.
*/ */
export async function getStakePoolAccounts( export async function getStakePoolAccounts(
connection: solanaWeb3.Connection, connection: Connection,
stakePoolProgramAddress: solanaWeb3.PublicKey, stakePoolProgramAddress: PublicKey,
): Promise<(StakePoolAccount | ValidatorListAccount)[]> { ): Promise<(StakePoolAccount | ValidatorListAccount)[] | undefined> {
try { const response = await connection.getProgramAccounts(stakePoolProgramAddress);
let response = await connection.getProgramAccounts(stakePoolProgramAddress);
const stakePoolAccounts = response.map(a => { return response.map(a => {
let decodedData; let decodedData;
if (a.account.data.readUInt8() === 1) { if (a.account.data.readUInt8() === 1) {
try { try {
decodedData = schema.StakePool.decode(a.account.data); decodedData = StakePoolLayout.decode(a.account.data);
} catch (error) { } catch (error) {
console.log('Could not decode StakeAccount. Error:', error); console.log('Could not decode StakeAccount. Error:', error);
decodedData = undefined; decodedData = undefined;
} }
} else if (a.account.data.readUInt8() === 2) { } else if (a.account.data.readUInt8() === 2) {
try { try {
decodedData = schema.ValidatorList.decodeUnchecked(a.account.data); decodedData = ValidatorListLayout.decode(a.account.data);
} catch (error) { } catch (error) {
console.log('Could not decode ValidatorList. Error:', error); console.log('Could not decode ValidatorList. Error:', error);
decodedData = undefined;
}
} else {
console.error(
`Could not decode. StakePoolAccount Enum is ${a.account.data.readUInt8()}, expected 1 or 2!`,
);
decodedData = undefined; decodedData = undefined;
} }
return {
pubkey: a.pubkey,
account: {
data: decodedData,
executable: a.account.executable,
lamports: a.account.lamports,
owner: a.account.owner,
},
};
});
return stakePoolAccounts;
} catch (error) {
console.log(error);
}
}
/**
* Helper function to pretty print a schema.PublicKey
* Pretty prints a PublicKey in base58 format */
export function prettyPrintPubKey(pubKey: solanaWeb3.PublicKey): string {
return new solanaWeb3.PublicKey(
new solanaWeb3.PublicKey(pubKey.toBuffer()).toBytes().reverse(),
).toString();
}
/**
* Helper function to pretty print a decoded account
*/
export function prettyPrintAccount(
account: ValidatorListAccount | StakePoolAccount,
): void {
console.log('Address:', account.pubkey.toString());
const sp = account.account.data;
if (typeof sp === 'undefined') {
console.log('Account could not be decoded');
}
for (const val in sp) {
if (sp[val] instanceof solanaWeb3.PublicKey) {
console.log(val, prettyPrintPubKey(sp[val]));
} else { } else {
console.log(val, sp[val]); console.error(
`Could not decode. StakePoolAccount Enum is ${a.account.data.readUInt8()}, expected 1 or 2!`,
);
decodedData = undefined;
}
return {
pubkey: a.pubkey,
account: {
data: decodedData,
executable: a.account.executable,
lamports: a.account.lamports,
owner: a.account.owner,
},
};
});
}
/**
* Creates instructions required to deposit sol to stake pool.
*/
export async function depositSol(
connection: Connection,
stakePoolAddress: PublicKey,
from: PublicKey,
lamports: number,
destinationTokenAccount?: PublicKey,
referrerTokenAccount?: PublicKey,
depositAuthority?: PublicKey
) {
const fromBalance = await connection.getBalance(from, 'confirmed');
if (fromBalance < lamports) {
throw new Error(
`Not enough SOL to deposit into pool. Maximum deposit amount is ${lamportsToSol(
fromBalance,
)} SOL.`,
);
}
const stakePoolAccount = await getStakePoolAccount(connection, stakePoolAddress);
const stakePool = stakePoolAccount.account.data;
// Ephemeral SOL account just to do the transfer
const userSolTransfer = new Keypair();
const signers: Signer[] = [userSolTransfer];
const instructions: TransactionInstruction[] = [];
// Create the ephemeral SOL account
instructions.push(
SystemProgram.transfer({
fromPubkey: from,
toPubkey: userSolTransfer.publicKey,
lamports,
}),
);
// Create token account if not specified
if (!destinationTokenAccount) {
destinationTokenAccount = await addAssociatedTokenAccount(
connection,
from,
stakePool.poolMint,
instructions,
);
}
const withdrawAuthority = await findWithdrawAuthorityProgramAddress(
STAKE_POOL_PROGRAM_ID,
stakePoolAddress,
);
instructions.push(
StakePoolInstruction.depositSol({
stakePool: stakePoolAddress,
reserveStake: stakePool.reserveStake,
fundingAccount: userSolTransfer.publicKey,
destinationPoolAccount: destinationTokenAccount,
managerFeeAccount: stakePool.managerFeeAccount,
referralPoolAccount: referrerTokenAccount ?? destinationTokenAccount,
poolMint: stakePool.poolMint,
lamports: lamports,
withdrawAuthority: withdrawAuthority,
depositAuthority: depositAuthority,
}),
);
return {
instructions,
signers,
};
}
/**
* Creates instructions required to withdraw stake from a stake pool.
*/
export async function withdrawStake(
connection: Connection,
stakePoolAddress: PublicKey,
tokenOwner: PublicKey,
amount: number,
useReserve = false,
voteAccountAddress?: PublicKey,
stakeReceiver?: PublicKey,
poolTokenAccount?: PublicKey,
) {
const stakePool = await getStakePoolAccount(connection, stakePoolAddress);
const poolAmount = solToLamports(amount);
if (!poolTokenAccount) {
poolTokenAccount = await Token.getAssociatedTokenAddress(
ASSOCIATED_TOKEN_PROGRAM_ID,
TOKEN_PROGRAM_ID,
stakePool.account.data.poolMint,
tokenOwner,
);
}
const tokenAccount = await getTokenAccount(
connection,
poolTokenAccount,
stakePool.account.data.poolMint,
);
if (!tokenAccount) {
throw new Error('Invalid token account');
}
// Check withdrawFrom balance
if (tokenAccount.amount.toNumber() < poolAmount) {
throw new Error(
`Not enough token balance to withdraw ${lamportsToSol(poolAmount)} pool tokens.
Maximum withdraw amount is ${lamportsToSol(tokenAccount.amount.toNumber())} pool tokens.`,
);
}
const withdrawAuthority = await findWithdrawAuthorityProgramAddress(
STAKE_POOL_PROGRAM_ID,
stakePoolAddress,
);
const withdrawAccounts: WithdrawAccount[] = [];
if (useReserve) {
withdrawAccounts.push({
stakeAddress: stakePool.account.data.reserveStake,
voteAddress: undefined,
poolAmount,
});
} else if (voteAccountAddress) {
const stakeAccountAddress = await findStakeProgramAddress(
STAKE_POOL_PROGRAM_ID,
voteAccountAddress,
stakePoolAddress,
);
const stakeAccount = await connection.getAccountInfo(stakeAccountAddress);
if (!stakeAccount) {
throw new Error('Invalid Stake Account');
}
const availableForWithdrawal = calcLamportsWithdrawAmount(
stakePool.account.data,
stakeAccount.lamports - MIN_STAKE_BALANCE,
);
if (availableForWithdrawal < poolAmount) {
// noinspection ExceptionCaughtLocallyJS
throw new Error(
`Not enough lamports available for withdrawal from ${stakeAccountAddress},
${poolAmount} asked, ${availableForWithdrawal} available.`,
);
}
withdrawAccounts.push({
stakeAddress: stakeAccountAddress,
voteAddress: voteAccountAddress,
poolAmount,
});
} else {
// Get the list of accounts to withdraw from
withdrawAccounts.push(
...(await prepareWithdrawAccounts(
connection,
stakePool.account.data,
stakePoolAddress,
poolAmount,
)),
);
}
// Construct transaction to withdraw from withdrawAccounts account list
const instructions: TransactionInstruction[] = [];
const userTransferAuthority = Keypair.generate();
const signers: Signer[] = [userTransferAuthority];
instructions.push(
Token.createApproveInstruction(
TOKEN_PROGRAM_ID,
poolTokenAccount,
userTransferAuthority.publicKey,
tokenOwner,
[],
poolAmount,
),
);
let totalRentFreeBalances = 0;
// Max 5 accounts to prevent an error: "Transaction too large"
const maxWithdrawAccounts = 5;
let i = 0;
// Go through prepared accounts and withdraw/claim them
for (const withdrawAccount of withdrawAccounts) {
if (i > maxWithdrawAccounts) {
break;
}
// Convert pool tokens amount to lamports
const solWithdrawAmount = Math.ceil(
calcLamportsWithdrawAmount(stakePool.account.data, withdrawAccount.poolAmount),
);
let infoMsg = `Withdrawing ◎${solWithdrawAmount},
from stake account ${withdrawAccount.stakeAddress?.toBase58()}`;
if (withdrawAccount.voteAddress) {
infoMsg = `${infoMsg}, delegated to ${withdrawAccount.voteAddress?.toBase58()}`;
}
console.info(infoMsg);
let stakeToReceive;
// Use separate mutable variable because withdraw might create a new account
if (!stakeReceiver) {
const stakeReceiverAccountBalance = await connection.getMinimumBalanceForRentExemption(
StakeProgram.space,
);
const stakeKeypair = newStakeAccount(tokenOwner, instructions, stakeReceiverAccountBalance);
signers.push(stakeKeypair);
totalRentFreeBalances += stakeReceiverAccountBalance;
stakeToReceive = stakeKeypair.publicKey;
} else {
stakeToReceive = stakeReceiver;
}
instructions.push(
StakePoolInstruction.withdrawStake({
stakePool: stakePoolAddress,
validatorList: stakePool.account.data.validatorList,
validatorStake: withdrawAccount.stakeAddress,
destinationStake: stakeToReceive,
destinationStakeAuthority: tokenOwner,
sourceTransferAuthority: userTransferAuthority.publicKey,
sourcePoolAccount: poolTokenAccount,
managerFeeAccount: stakePool.account.data.managerFeeAccount,
poolMint: stakePool.account.data.poolMint,
poolTokens: withdrawAccount.poolAmount,
withdrawAuthority,
})
);
i++;
}
return {
instructions,
signers,
stakeReceiver,
totalRentFreeBalances,
};
}
/**
* Creates instructions required to withdraw SOL directly from a stake pool.
*/
export async function withdrawSol(
connection: Connection,
stakePoolAddress: PublicKey,
tokenOwner: PublicKey,
solReceiver: PublicKey,
amount: number,
solWithdrawAuthority?: PublicKey,
) {
const stakePool = await getStakePoolAccount(connection, stakePoolAddress);
const poolAmount = solToLamports(amount);
const poolTokenAccount = await Token.getAssociatedTokenAddress(
ASSOCIATED_TOKEN_PROGRAM_ID,
TOKEN_PROGRAM_ID,
stakePool.account.data.poolMint,
tokenOwner,
);
const tokenAccount = await getTokenAccount(
connection,
poolTokenAccount,
stakePool.account.data.poolMint,
);
if (!tokenAccount) {
throw new Error('Invalid token account');
}
// Check withdrawFrom balance
if (tokenAccount.amount.toNumber() < poolAmount) {
throw new Error(
`Not enough token balance to withdraw ${lamportsToSol(poolAmount)} pool tokens.
Maximum withdraw amount is ${lamportsToSol(tokenAccount.amount.toNumber())} pool tokens.`,
);
}
// Construct transaction to withdraw from withdrawAccounts account list
const instructions: TransactionInstruction[] = [];
const userTransferAuthority = Keypair.generate();
const signers: Signer[] = [userTransferAuthority];
instructions.push(
Token.createApproveInstruction(
TOKEN_PROGRAM_ID,
poolTokenAccount,
userTransferAuthority.publicKey,
tokenOwner,
[],
poolAmount,
),
);
const poolWithdrawAuthority = await findWithdrawAuthorityProgramAddress(
STAKE_POOL_PROGRAM_ID,
stakePoolAddress,
);
if (solWithdrawAuthority) {
const expectedSolWithdrawAuthority = stakePool.account.data.solWithdrawAuthority;
if (!expectedSolWithdrawAuthority) {
throw new Error('SOL withdraw authority specified in arguments but stake pool has none');
}
if (solWithdrawAuthority.toBase58() != expectedSolWithdrawAuthority.toBase58()) {
throw new Error(
`Invalid deposit withdraw specified, expected ${expectedSolWithdrawAuthority.toBase58()}, received ${solWithdrawAuthority.toBase58()}`,
);
} }
} }
console.log('Executable?:', account.account.executable);
console.log('Lamports:', account.account.lamports); const withdrawTransaction = StakePoolInstruction.withdrawSol({
console.log('Owner PubKey:', account.account.owner.toString()); stakePool: stakePoolAddress,
withdrawAuthority: poolWithdrawAuthority,
reserveStake: stakePool.account.data.reserveStake,
sourcePoolAccount: poolTokenAccount,
sourceTransferAuthority: userTransferAuthority.publicKey,
destinationSystemAccount: solReceiver,
managerFeeAccount: stakePool.account.data.managerFeeAccount,
poolMint: stakePool.account.data.poolMint,
poolTokens: poolAmount,
solWithdrawAuthority,
});
instructions.push(withdrawTransaction);
return {
instructions,
signers,
};
} }

View File

@ -0,0 +1,381 @@
/**
* Based on https://github.com/solana-labs/solana-web3.js/blob/master/src/stake-program.ts
*/
import { encodeData, decodeData, InstructionType } from './copied-from-solana-web3/instruction';
import {
PublicKey,
TransactionInstruction,
StakeProgram,
SystemProgram,
SYSVAR_CLOCK_PUBKEY,
SYSVAR_STAKE_HISTORY_PUBKEY,
} from '@solana/web3.js';
import * as BufferLayout from '@solana/buffer-layout';
import { TOKEN_PROGRAM_ID } from '@solana/spl-token';
import { STAKE_POOL_PROGRAM_ID } from "./constants";
/**
* An enumeration of valid StakePoolInstructionType's
*/
export type StakePoolInstructionType =
| 'DepositStake'
| 'DepositSol'
| 'WithdrawStake'
| 'WithdrawSol';
/**
* An enumeration of valid stake InstructionType's
* @internal
*/
export const STAKE_POOL_INSTRUCTION_LAYOUTS: {
[type in StakePoolInstructionType]: InstructionType;
} = Object.freeze({
DepositStake: {
index: 9,
layout: BufferLayout.struct([BufferLayout.u8('instruction')]),
},
/// Withdraw the token from the pool at the current ratio.
WithdrawStake: {
index: 10,
layout: BufferLayout.struct([BufferLayout.u8('instruction'), BufferLayout.ns64('poolTokens')]),
},
/// Deposit SOL directly into the pool's reserve account. The output is a "pool" token
/// representing ownership into the pool. Inputs are converted to the current ratio.
DepositSol: {
index: 14,
layout: BufferLayout.struct([BufferLayout.u8('instruction'), BufferLayout.ns64('lamports')]),
},
/// Withdraw SOL directly from the pool's reserve account. Fails if the
/// reserve does not have enough SOL.
WithdrawSol: {
index: 16,
layout: BufferLayout.struct([BufferLayout.u8('instruction'), BufferLayout.ns64('poolTokens')]),
},
});
/**
* Deposit stake pool instruction params
*/
export type DepositStakeParams = {
stakePool: PublicKey;
validatorList: PublicKey;
depositAuthority: PublicKey;
withdrawAuthority: PublicKey;
depositStake: PublicKey;
validatorStake: PublicKey;
reserveStake: PublicKey;
destinationPoolAccount: PublicKey;
managerFeeAccount: PublicKey;
referralPoolAccount: PublicKey;
poolMint: PublicKey;
};
/**
* Withdraw stake pool instruction params
*/
export type WithdrawStakeParams = {
stakePool: PublicKey;
validatorList: PublicKey;
withdrawAuthority: PublicKey;
validatorStake: PublicKey;
destinationStake: PublicKey;
destinationStakeAuthority: PublicKey;
sourceTransferAuthority: PublicKey;
sourcePoolAccount: PublicKey;
managerFeeAccount: PublicKey;
poolMint: PublicKey;
poolTokens: number;
};
/**
* Withdraw sol instruction params
*/
export type WithdrawSolParams = {
stakePool: PublicKey;
sourcePoolAccount: PublicKey;
withdrawAuthority: PublicKey;
reserveStake: PublicKey;
destinationSystemAccount: PublicKey;
sourceTransferAuthority: PublicKey;
solWithdrawAuthority?: PublicKey | undefined;
managerFeeAccount: PublicKey;
poolMint: PublicKey;
poolTokens: number;
};
/**
* Deposit sol instruction params
*/
export type DepositSolParams = {
stakePool: PublicKey;
depositAuthority?: PublicKey | undefined;
withdrawAuthority: PublicKey;
reserveStake: PublicKey;
fundingAccount: PublicKey;
destinationPoolAccount: PublicKey;
managerFeeAccount: PublicKey;
referralPoolAccount: PublicKey;
poolMint: PublicKey;
lamports: number;
};
/**
* Stake Pool Instruction class
*/
export class StakePoolInstruction {
/**
* Creates a transaction instruction to deposit SOL into a stake pool.
*/
static depositStake(params: DepositStakeParams): TransactionInstruction {
const {
stakePool,
validatorList,
depositAuthority,
withdrawAuthority,
depositStake,
validatorStake,
reserveStake,
destinationPoolAccount,
managerFeeAccount,
referralPoolAccount,
poolMint,
} = params;
const type = STAKE_POOL_INSTRUCTION_LAYOUTS.DepositStake;
const data = encodeData(type);
const keys = [
{ pubkey: stakePool, isSigner: false, isWritable: true },
{ pubkey: validatorList, isSigner: false, isWritable: true },
{ pubkey: depositAuthority, isSigner: false, isWritable: false },
{ pubkey: withdrawAuthority, isSigner: false, isWritable: false },
{ pubkey: depositStake, isSigner: false, isWritable: true },
{ pubkey: validatorStake, isSigner: false, isWritable: true },
{ pubkey: reserveStake, isSigner: false, isWritable: true },
{ pubkey: destinationPoolAccount, isSigner: false, isWritable: true },
{ pubkey: managerFeeAccount, isSigner: false, isWritable: true },
{ pubkey: referralPoolAccount, isSigner: false, isWritable: true },
{ pubkey: poolMint, isSigner: false, isWritable: true },
{ pubkey: SYSVAR_CLOCK_PUBKEY, isSigner: false, isWritable: false },
{ pubkey: SYSVAR_STAKE_HISTORY_PUBKEY, isSigner: false, isWritable: false },
{ pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false },
{ pubkey: StakeProgram.programId, isSigner: false, isWritable: false },
];
return new TransactionInstruction({
programId: STAKE_POOL_PROGRAM_ID,
keys,
data,
});
}
/**
* Creates a transaction instruction to withdraw SOL from a stake pool.
*/
static depositSol(params: DepositSolParams): TransactionInstruction {
const {
stakePool,
withdrawAuthority,
depositAuthority,
reserveStake,
fundingAccount,
destinationPoolAccount,
managerFeeAccount,
referralPoolAccount,
poolMint,
lamports,
} = params;
const type = STAKE_POOL_INSTRUCTION_LAYOUTS.DepositSol;
const data = encodeData(type, { lamports });
const keys = [
{ pubkey: stakePool, isSigner: false, isWritable: true },
{ pubkey: withdrawAuthority, isSigner: false, isWritable: false },
{ pubkey: reserveStake, isSigner: false, isWritable: true },
{ pubkey: fundingAccount, isSigner: true, isWritable: true },
{ pubkey: destinationPoolAccount, isSigner: false, isWritable: true },
{ pubkey: managerFeeAccount, isSigner: false, isWritable: true },
{ pubkey: referralPoolAccount, isSigner: false, isWritable: true },
{ pubkey: poolMint, isSigner: false, isWritable: true },
{ pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
{ pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false },
];
if (depositAuthority) {
keys.push({
pubkey: depositAuthority,
isSigner: true,
isWritable: false,
});
}
return new TransactionInstruction({
programId: STAKE_POOL_PROGRAM_ID,
keys,
data,
});
}
/**
* Creates a transaction instruction to withdraw SOL from a stake pool.
*/
static withdrawStake(params: WithdrawStakeParams): TransactionInstruction {
const {
stakePool,
validatorList,
withdrawAuthority,
validatorStake,
destinationStake,
destinationStakeAuthority,
sourceTransferAuthority,
sourcePoolAccount,
managerFeeAccount,
poolMint,
poolTokens,
} = params;
const type = STAKE_POOL_INSTRUCTION_LAYOUTS.WithdrawStake;
const data = encodeData(type, { poolTokens });
const keys = [
{ pubkey: stakePool, isSigner: false, isWritable: true },
{ pubkey: validatorList, isSigner: false, isWritable: true },
{ pubkey: withdrawAuthority, isSigner: false, isWritable: false },
{ pubkey: validatorStake, isSigner: false, isWritable: true },
{ pubkey: destinationStake, isSigner: false, isWritable: true },
{ pubkey: destinationStakeAuthority, isSigner: false, isWritable: false },
{ pubkey: sourceTransferAuthority, isSigner: true, isWritable: false },
{ pubkey: sourcePoolAccount, isSigner: false, isWritable: true },
{ pubkey: managerFeeAccount, isSigner: false, isWritable: true },
{ pubkey: poolMint, isSigner: false, isWritable: true },
{ pubkey: SYSVAR_CLOCK_PUBKEY, isSigner: false, isWritable: false },
{ pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false },
{ pubkey: StakeProgram.programId, isSigner: false, isWritable: false },
];
return new TransactionInstruction({
programId: STAKE_POOL_PROGRAM_ID,
keys,
data,
});
}
/**
* Creates a transaction instruction to withdraw SOL from a stake pool.
*/
static withdrawSol(params: WithdrawSolParams): TransactionInstruction {
const {
stakePool,
withdrawAuthority,
sourceTransferAuthority,
sourcePoolAccount,
reserveStake,
destinationSystemAccount,
managerFeeAccount,
solWithdrawAuthority,
poolMint,
poolTokens,
} = params;
const type = STAKE_POOL_INSTRUCTION_LAYOUTS.WithdrawSol;
const data = encodeData(type, { poolTokens });
const keys = [
{ pubkey: stakePool, isSigner: false, isWritable: true },
{ pubkey: withdrawAuthority, isSigner: false, isWritable: false },
{ pubkey: sourceTransferAuthority, isSigner: true, isWritable: false },
{ pubkey: sourcePoolAccount, isSigner: false, isWritable: true },
{ pubkey: reserveStake, isSigner: false, isWritable: true },
{ pubkey: destinationSystemAccount, isSigner: false, isWritable: true },
{ pubkey: managerFeeAccount, isSigner: false, isWritable: true },
{ pubkey: poolMint, isSigner: false, isWritable: true },
{ pubkey: SYSVAR_CLOCK_PUBKEY, isSigner: false, isWritable: false },
{ pubkey: SYSVAR_STAKE_HISTORY_PUBKEY, isSigner: false, isWritable: false },
{ pubkey: StakeProgram.programId, isSigner: false, isWritable: false },
{ pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false },
];
if (solWithdrawAuthority) {
keys.push({
pubkey: solWithdrawAuthority,
isSigner: true,
isWritable: false,
});
}
return new TransactionInstruction({
programId: STAKE_POOL_PROGRAM_ID,
keys,
data,
});
}
/**
* Decode a deposit stake pool instruction and retrieve the instruction params.
*/
static decodeDepositStake(instruction: TransactionInstruction): DepositStakeParams {
this.checkProgramId(instruction.programId);
this.checkKeyLength(instruction.keys, 11);
decodeData(STAKE_POOL_INSTRUCTION_LAYOUTS.DepositStake, instruction.data);
return {
stakePool: instruction.keys[0].pubkey,
validatorList: instruction.keys[1].pubkey,
depositAuthority: instruction.keys[2].pubkey,
withdrawAuthority: instruction.keys[3].pubkey,
depositStake: instruction.keys[4].pubkey,
validatorStake: instruction.keys[5].pubkey,
reserveStake: instruction.keys[6].pubkey,
destinationPoolAccount: instruction.keys[7].pubkey,
managerFeeAccount: instruction.keys[8].pubkey,
referralPoolAccount: instruction.keys[9].pubkey,
poolMint: instruction.keys[10].pubkey,
};
}
/**
* Decode a deposit sol instruction and retrieve the instruction params.
*/
static decodeDepositSol(instruction: TransactionInstruction): DepositSolParams {
this.checkProgramId(instruction.programId);
this.checkKeyLength(instruction.keys, 9);
const { amount } = decodeData(STAKE_POOL_INSTRUCTION_LAYOUTS.DepositSol, instruction.data);
return {
stakePool: instruction.keys[0].pubkey,
depositAuthority: instruction.keys[1].pubkey,
withdrawAuthority: instruction.keys[2].pubkey,
reserveStake: instruction.keys[3].pubkey,
fundingAccount: instruction.keys[4].pubkey,
destinationPoolAccount: instruction.keys[5].pubkey,
managerFeeAccount: instruction.keys[6].pubkey,
referralPoolAccount: instruction.keys[7].pubkey,
poolMint: instruction.keys[8].pubkey,
lamports: amount,
};
}
/**
* @internal
*/
private static checkProgramId(programId: PublicKey) {
if (!programId.equals(StakeProgram.programId)) {
throw new Error('Invalid instruction; programId is not StakeProgram');
}
}
/**
* @internal
*/
private static checkKeyLength(keys: Array<any>, expectedLength: number) {
if (keys.length < expectedLength) {
throw new Error(
`Invalid instruction; found ${keys.length} keys, expected at least ${expectedLength}`,
);
}
}
}

View File

@ -1,65 +0,0 @@
import * as index from './index.js';
import * as schema from './schema.js';
import BN from 'bn.js';
import assert, {deepStrictEqual} from 'assert';
import {SOLANA_SCHEMA, PublicKey, Connection} from '@solana/web3.js';
// First populate schema
schema.addStakePoolSchema(SOLANA_SCHEMA);
describe('Integration test', () => {
it('should successfully decode all validators from devnet', async () => {
/**
* Full integration test:
* Makes a connection to devnet, gets all stake pool accounts there,
* decodes them, and prints their details.
*/
const connection = new Connection(
'https://api.devnet.solana.com/',
'confirmed',
);
const STAKE_POOL_PROGRAM_ADDR = new PublicKey(
'poo1B9L9nR3CrcaziKVYVpRX6A9Y1LAXYasjjfCbApj',
);
const accounts = await index.getStakePoolAccounts(
connection,
STAKE_POOL_PROGRAM_ADDR,
);
console.log('Number of stake pool accounts in devnet: ', accounts.length);
accounts.map(account => {
index.prettyPrintAccount(account);
console.log('\n');
});
});
it('should successfully decode all validators from testnet', async () => {
/**
* Full integration test:
* Makes a connection to testnet, gets all stake pool accounts there,
* decodes them, and prints their details.
* Testnet presents a greater challenge due to the presence of old stake pool program accounts
*/
const connection = new Connection(
'https://api.testnet.solana.com/',
'confirmed',
);
const STAKE_POOL_PROGRAM_ADDR = new PublicKey(
'poo1B9L9nR3CrcaziKVYVpRX6A9Y1LAXYasjjfCbApj',
);
const accounts = await index.getStakePoolAccounts(
connection,
STAKE_POOL_PROGRAM_ADDR,
);
console.log('Number of stake pool accounts in testnet: ', accounts.length);
accounts.map(account => {
index.prettyPrintAccount(account);
console.log('\n');
});
});
});

View File

@ -0,0 +1,152 @@
import { publicKey, struct, u32, u64, u8, option, vec } from '@project-serum/borsh';
import { Lockup, PublicKey } from '@solana/web3.js';
import { AccountInfo } from "@solana/spl-token";
import BN from 'bn.js';
export interface Fee {
denominator: BN;
numerator: BN;
}
const feeFields = [u64('denominator'), u64('numerator')];
/**
* AccountLayout.encode from "@solana/spl-token" doesn't work
*/
export const AccountLayout = struct<AccountInfo>([
publicKey('mint'),
publicKey('owner'),
u64('amount'),
u32('delegateOption'),
publicKey('delegate'),
u8('state'),
u32('isNativeOption'),
u64('isNative'),
u64('delegatedAmount'),
u32('closeAuthorityOption'),
publicKey('closeAuthority'),
]);
export enum AccountType {
Uninitialized,
StakePool,
ValidatorList,
}
export interface StakePool {
accountType: AccountType;
manager: PublicKey;
staker: PublicKey;
stakeDepositAuthority: PublicKey;
stakeWithdrawBumpSeed: number;
validatorList: PublicKey;
reserveStake: PublicKey;
poolMint: PublicKey;
managerFeeAccount: PublicKey;
tokenProgramId: PublicKey;
totalLamports: BN;
poolTokenSupply: BN;
lastUpdateEpoch: BN;
lockup: Lockup;
epochFee: Fee;
nextEpochFee?: Fee | undefined;
preferredDepositValidatorVoteAddress?: PublicKey | undefined;
preferredWithdrawValidatorVoteAddress?: PublicKey | undefined;
stakeDepositFee: Fee;
stakeWithdrawalFee: Fee;
nextWithdrawalFee?: Fee | undefined;
stakeReferralFee: number;
solDepositAuthority?: PublicKey | undefined;
solDepositFee: Fee;
solReferralFee: number;
solWithdrawAuthority?: PublicKey | undefined;
solWithdrawalFee: Fee;
nextSolWithdrawalFee?: Fee | undefined;
lastEpochPoolTokenSupply: BN;
lastEpochTotalLamports: BN;
}
export const StakePoolLayout = struct<StakePool>([
u8('accountType'),
publicKey('manager'),
publicKey('staker'),
publicKey('stakeDepositAuthority'),
u8('stakeWithdrawBumpSeed'),
publicKey('validatorList'),
publicKey('reserveStake'),
publicKey('poolMint'),
publicKey('managerFeeAccount'),
publicKey('tokenProgramId'),
u64('totalLamports'),
u64('poolTokenSupply'),
u64('lastUpdateEpoch'),
struct([u64('unixTimestamp'), u64('epoch'), publicKey('custodian')], 'lockup'),
struct(feeFields, 'epochFee'),
option(struct(feeFields), 'nextEpochFee'),
option(publicKey(), 'preferredDepositValidatorVoteAddress'),
option(publicKey(), 'preferredWithdrawValidatorVoteAddress'),
struct(feeFields, 'stakeDepositFee'),
struct(feeFields, 'stakeWithdrawalFee'),
option(struct(feeFields), 'nextWithdrawalFee'),
u8('stakeReferralFee'),
option(publicKey(), 'solDepositAuthority'),
struct(feeFields, 'solDepositFee'),
u8('solReferralFee'),
option(publicKey(), 'solWithdrawAuthority'),
struct(feeFields, 'solWithdrawalFee'),
option(struct(feeFields), 'nextSolWithdrawalFee'),
u64('lastEpochPoolTokenSupply'),
u64('lastEpochTotalLamports'),
]);
export enum ValidatorStakeInfoStatus {
Active,
DeactivatingTransient,
ReadyForRemoval,
}
export interface ValidatorStakeInfo {
status: ValidatorStakeInfoStatus;
voteAccountAddress: PublicKey;
activeStakeLamports: BN;
transientStakeLamports: BN;
transientSeedSuffixStart: BN;
transientSeedSuffixEnd: BN;
lastUpdateEpoch: BN;
}
export const ValidatorStakeInfoLayout = struct<ValidatorStakeInfo>([
/// Amount of active stake delegated to this validator
/// Note that if `last_update_epoch` does not match the current epoch then
/// this field may not be accurate
u64('activeStakeLamports'),
/// Amount of transient stake delegated to this validator
/// Note that if `last_update_epoch` does not match the current epoch then
/// this field may not be accurate
u64('transientStakeLamports'),
/// Last epoch the active and transient stake lamports fields were updated
u64('lastUpdateEpoch'),
/// Start of the validator transient account seed suffixes
u64('transientSeedSuffixStart'),
/// End of the validator transient account seed suffixes
u64('transientSeedSuffixEnd'),
/// Status of the validator stake account
u8('status'),
/// Validator vote account address
publicKey('voteAccountAddress'),
]);
export interface ValidatorList {
/// Account type, must be ValidatorList currently
accountType: number;
/// Maximum allowable number of validators
maxValidators: number;
/// List of stake info for each validator in the pool
validators: ValidatorStakeInfo[];
}
export const ValidatorListLayout = struct<ValidatorList>([
u8('accountType'),
u32('maxValidators'),
vec(ValidatorStakeInfoLayout, 'validators'),
]);

View File

@ -1,139 +0,0 @@
import {Schema, serialize, deserializeUnchecked} from 'borsh';
import BN from 'bn.js';
import {Struct, Enum, PublicKey} from '@solana/web3.js';
export class Fee extends Struct {
denominator: BN;
numerator: BN;
}
export class AccountType extends Enum {}
export class AccountTypeEnum extends Struct {}
export enum AccountTypeKind {
Uninitialized = 'Uninitialized',
StakePool = 'StakePool',
ValidatorList = 'ValidatorList',
}
export class StakePool extends Struct {
accountType: AccountType;
manager: PublicKey;
staker: PublicKey;
depositAuthority: PublicKey;
withdrawBumpSeed: number;
validatorList: PublicKey;
reserveStake: PublicKey;
poolMint: PublicKey;
managerFeeAccount: PublicKey;
totalStakeLamports: BN;
poolTokenSupply: BN;
lastUpdateEpoch: BN;
fee: Fee;
}
export class ValidatorList extends Struct {
accountType: AccountType;
maxValidators: number;
validators: [ValidatorStakeInfo];
}
export class ValidatorStakeInfo extends Struct {
status: StakeStatus;
voteAccountAddress: PublicKey;
stakeLamports: BN;
lastUpdateEpoch: BN;
}
export class StakeStatus extends Enum {}
export class StakeStatusEnum extends Struct {}
export enum StakeStatusKind {
Active = 'Active',
DeactivatingTransient = 'DeactivatingTransient',
ReadyForRemoval = 'ReadyForRemoval',
}
export function addStakePoolSchema(schema: Schema): void {
/**
* Borsh requires something called a Schema,
* which is a Map (key-value pairs) that tell borsh how to deserialise the raw data
* This function adds a new schema to an existing schema object.
*/
schema.set(PublicKey, {
kind: 'struct',
fields: [['_bn', 'u256']],
});
schema.set(Fee, {
kind: 'struct',
fields: [
['denominator', 'u64'],
['numerator', 'u64'],
],
});
schema.set(AccountType, {
kind: 'enum',
field: 'enum',
values: [
// if the account has not been initialized, the enum will be 0
[AccountTypeKind.Uninitialized, AccountTypeEnum],
[AccountTypeKind.StakePool, AccountTypeEnum],
[AccountTypeKind.ValidatorList, AccountTypeEnum],
],
});
schema.set(AccountTypeEnum, {kind: 'struct', fields: []});
schema.set(StakePool, {
kind: 'struct',
fields: [
['accountType', AccountType],
['manager', PublicKey],
['staker', PublicKey],
['depositAuthority', PublicKey],
['withdrawBumpSeed', 'u8'],
['validatorList', PublicKey],
['reserveStake', PublicKey],
['poolMint', PublicKey],
['managerFeeAccount', PublicKey],
['tokenProgramId', PublicKey],
['totalStakeLamports', 'u64'],
['poolTokenSupply', 'u64'],
['lastUpdateEpoch', 'u64'],
['fee', Fee],
],
});
schema.set(ValidatorList, {
kind: 'struct',
fields: [
['accountType', AccountType],
['maxValidators', 'u32'],
['validators', [ValidatorStakeInfo]],
],
});
schema.set(StakeStatus, {
kind: 'enum',
field: 'enum',
values: [
[StakeStatusKind.Active, StakeStatusEnum],
[StakeStatusKind.DeactivatingTransient, StakeStatusEnum],
[StakeStatusKind.ReadyForRemoval, StakeStatusEnum],
],
});
schema.set(StakeStatusEnum, {kind: 'struct', fields: []});
schema.set(ValidatorStakeInfo, {
kind: 'struct',
fields: [
['status', StakeStatus],
['voteAccountAddress', PublicKey],
['stakeLamports', 'u64'],
['lastUpdateEpoch', 'u64'],
],
});
}

View File

@ -1,215 +0,0 @@
import * as index from './index.js';
import * as schema from './schema.js';
import BN from 'bn.js';
import assert, {deepStrictEqual} from 'assert';
import {SOLANA_SCHEMA, PublicKey, Connection} from '@solana/web3.js';
// First populate schema
schema.addStakePoolSchema(SOLANA_SCHEMA);
function deepStrictEqualBN(decodedData: object, expectedData: object) {
/**
* Helper function to do deep equality check because BNs are not equal.
* TODO: write this function recursively. For now, sufficient.
*/
for (const key in decodedData) {
if (expectedData[key] instanceof BN) {
assert.ok(expectedData[key].eq(decodedData[key]));
} else {
if (decodedData[key] instanceof Object) {
for (const subkey in decodedData[key]) {
if (decodedData[key][subkey] instanceof Object) {
if (decodedData[key][subkey] instanceof BN) {
assert.ok(decodedData[key][subkey].eq(expectedData[key][subkey]));
} else {
for (const subsubkey in decodedData[key][subkey]) {
console.log(decodedData[key][subkey][subsubkey]);
if (decodedData[key][subkey][subsubkey] instanceof BN) {
assert.ok(
decodedData[key][subkey][subsubkey].eq(
expectedData[key][subkey][subsubkey],
),
);
} else {
assert.deepStrictEqual(
expectedData[key][subkey][subsubkey],
decodedData[key][subkey][subsubkey],
);
}
}
}
} else {
assert.strictEqual(
decodedData[key][subkey],
expectedData[key][subkey],
);
}
}
} else {
assert.strictEqual(decodedData[key], expectedData[key]);
}
}
}
}
describe('schema.decode', () => {
describe('StakePoolAccount', () => {
it('should successfully decode StakePoolAccount account data', () => {
const expectedData = new schema.StakePool({
accountType: new schema.AccountType({
StakePool: new schema.AccountTypeEnum({}),
}),
manager: new PublicKey(
new BN(
'dc23cda2ad09ddec126f89ed7f67d06a4d167cca996503f1a1b3b5a13625964f',
'hex',
),
),
staker: new PublicKey(
new BN(
'dc23cda2ad09ddec126f89ed7f67d06a4d167cca996503f1a1b3b5a13625964f',
'hex',
),
),
depositAuthority: new PublicKey(
new BN(
new Buffer(
'5911e7451a1a854fdc9e495081790f293eba623f8ec7e2b9d34a5fd25c7009bb',
'hex',
),
),
),
withdrawBumpSeed: 255,
validatorList: new PublicKey(
new BN(
'7103ba4895b8804263197364da9e791db96ec8f0c8ca184dd666e69013838610',
'hex',
),
),
reserveStake: new PublicKey(
new BN(
'74a5b1ab8442103baa8bd39ab8494eb034e96035ac664e1693bb3eef458761ee',
'hex',
),
),
poolMint: new PublicKey(
new BN(
'8722bf107b95d2620008d256b18c13fa3a46ab7f643c24cf7656f57267563e00',
'hex',
),
),
managerFeeAccount: new PublicKey(
new BN(
new Buffer(
'b783b4dcd341cbca22e781bbd49b2d16908a844a21b98e26b69d44fc50e1db0f',
'hex',
),
),
),
tokenProgramId: new PublicKey(
new BN(
'a900ff7e85f58c3a91375b5fed85b41cac79ebce46e1cbd993a165d7e1f6dd06',
'hex',
),
),
totalStakeLamports: new BN('0', 'hex'),
poolTokenSupply: new BN('0', 'hex'),
lastUpdateEpoch: new BN('7c', 'hex'),
fee: new schema.Fee({
denominator: new BN('3e8', 'hex'),
numerator: new BN('38', 'hex'),
}),
});
const decodedData = schema.StakePool.decode(expectedData.encode());
deepStrictEqualBN(decodedData, expectedData);
});
});
describe('ValidatorListAccount', () => {
it('should successfully decode ValidatorListAccount account data', () => {
const expectedData = new schema.ValidatorList({
accountType: new schema.AccountType({
ValidatorList: new schema.AccountTypeEnum({}),
}),
maxValidators: 10,
validators: [],
});
const decodedData = schema.ValidatorList.decode(expectedData.encode());
assert.deepStrictEqual(decodedData, expectedData);
});
it('should successfully decode ValidatorListAccount with nonempty ValidatorInfo', () => {
// TODO also test for decoding ValidatorListAccount with actual ValidatorInfo
// Do this once we have a stake pool with validators deployed on testnet
const expectedData = new schema.ValidatorList({
accountType: new schema.AccountType({
ValidatorList: new schema.AccountTypeEnum({}),
}),
maxValidators: 100,
validators: [
new schema.ValidatorStakeInfo({
status: new schema.StakeStatus({
Active: new schema.StakeStatusEnum({}),
}),
voteAccountAddress: new PublicKey(
new BN(
'a9946a889af14fd3c9b33d5df309489d9699271a6b09ff3190fcb41cf21a2f8c',
'hex',
),
),
stakeLamports: new BN('0', 'hex'),
lastUpdateEpoch: new BN('c3', 'hex'),
}),
new schema.ValidatorStakeInfo({
status: new schema.StakeStatus({
Active: new schema.StakeStatusEnum({}),
}),
voteAccountAddress: new PublicKey(
new BN(
'3796d40645ee07e3c64117e3f73430471d4c40465f696ebc9b034c1fc06a9f7d',
'hex',
),
),
stakeLamports: new BN('0', 'hex'),
lastUpdateEpoch: new BN('c3', 'hex'),
}),
new schema.ValidatorStakeInfo({
status: new schema.StakeStatus({
Active: new schema.StakeStatusEnum({}),
}),
voteAccountAddress: new PublicKey(
new BN(
'e4e37d6f2e80c0bb0f3da8a06304e57be5cda6efa2825b86780aa320d9784cf8',
'hex',
),
),
stakeLamports: new BN('0', 'hex'),
lastUpdateEpoch: new BN('c3', 'hex'),
}),
],
});
const decodedData = schema.ValidatorList.decode(expectedData.encode());
deepStrictEqualBN(decodedData, expectedData);
});
});
});
describe('index.ts/PrettyPrintPubkey', () => {
it('should successfully pretty print a pubkey', () => {
assert.equal(
index.prettyPrintPubKey(
new PublicKey(
new BN(
'99572085579321386496717000324290408927851378839748241098946587626478579848783',
),
),
),
'6MfzrQUzB2mozveRWU9a77zMoQzSrYa4Gq46KswjupQB',
);
});
});

View File

@ -0,0 +1,4 @@
export * from './math'
export * from './program-address'
export * from './stake'
export * from './token'

View File

@ -0,0 +1,24 @@
import BN from "bn.js";
import { LAMPORTS_PER_SOL } from "@solana/web3.js";
export function solToLamports(amount: number): number {
if (isNaN(amount)) return Number(0);
return Number(amount * LAMPORTS_PER_SOL);
}
export function lamportsToSol(lamports: number | BN): number {
if (typeof lamports === 'number') {
return Math.abs(lamports) / LAMPORTS_PER_SOL;
}
let signMultiplier = 1;
if (lamports.isNeg()) {
signMultiplier = -1;
}
const absLamports = lamports.abs();
const lamportsString = absLamports.toString(10).padStart(10, '0');
const splitIndex = lamportsString.length - 9;
const solString = lamportsString.slice(0, splitIndex) + '.' + lamportsString.slice(splitIndex);
return signMultiplier * parseFloat(solString);
}

View File

@ -0,0 +1,53 @@
import { PublicKey } from "@solana/web3.js";
import BN from "bn.js";
import { TRANSIENT_STAKE_SEED_PREFIX } from "../constants";
/**
* Generates the withdraw authority program address for the stake pool
*/
export async function findWithdrawAuthorityProgramAddress(
programId: PublicKey,
stakePoolAddress: PublicKey,
) {
const [publicKey] = await PublicKey.findProgramAddress(
[stakePoolAddress.toBuffer(), Buffer.from('withdraw')],
programId,
);
return publicKey;
}
/**
* Generates the stake program address for a validator's vote account
*/
export async function findStakeProgramAddress(
programId: PublicKey,
voteAccountAddress: PublicKey,
stakePoolAddress: PublicKey,
) {
const [publicKey] = await PublicKey.findProgramAddress(
[voteAccountAddress.toBuffer(), stakePoolAddress.toBuffer()],
programId,
);
return publicKey;
}
/**
* Generates the stake program address for a validator's vote account
*/
export async function findTransientStakeProgramAddress(
programId: PublicKey,
voteAccountAddress: PublicKey,
stakePoolAddress: PublicKey,
seed: BN,
) {
const [publicKey] = await PublicKey.findProgramAddress(
[
TRANSIENT_STAKE_SEED_PREFIX,
voteAccountAddress.toBuffer(),
stakePoolAddress.toBuffer(),
new Uint8Array(seed.toArray('le', 8)),
],
programId,
);
return publicKey;
}

View File

@ -0,0 +1,196 @@
import {
Connection,
Keypair,
PublicKey, StakeProgram,
SystemProgram,
TransactionInstruction
} from "@solana/web3.js";
import { findStakeProgramAddress, findTransientStakeProgramAddress } from "./program-address";
import BN from "bn.js";
import { lamportsToSol } from "./math";
import { WithdrawAccount } from "../index";
import {
StakePool,
ValidatorList,
ValidatorListLayout,
ValidatorStakeInfoStatus
} from "../layouts";
import { STAKE_POOL_PROGRAM_ID } from "../constants";
export async function prepareWithdrawAccounts(
connection: Connection,
stakePool: StakePool,
stakePoolAddress: PublicKey,
amount: number,
): Promise<WithdrawAccount[]> {
const validatorListAcc = await connection.getAccountInfo(stakePool.validatorList);
const validatorList = ValidatorListLayout.decode(validatorListAcc!.data) as ValidatorList;
if (!validatorList?.validators || validatorList?.validators.length == 0) {
throw new Error('No accounts found');
}
let accounts = [] as Array<{
type: 'preferred' | 'active' | 'transient' | 'reserve';
voteAddress?: PublicKey | undefined;
stakeAddress: PublicKey;
lamports: number;
}>;
// Prepare accounts
for (const validator of validatorList.validators) {
if (validator.status !== ValidatorStakeInfoStatus.Active) {
continue;
}
const stakeAccountAddress = await findStakeProgramAddress(
STAKE_POOL_PROGRAM_ID,
validator.voteAccountAddress,
stakePoolAddress,
);
if (!validator.activeStakeLamports.isZero()) {
const isPreferred =
stakePool.preferredWithdrawValidatorVoteAddress &&
stakePool.preferredWithdrawValidatorVoteAddress!.toBase58() ==
validator.voteAccountAddress.toBase58();
accounts.push({
type: isPreferred ? 'preferred' : 'active',
voteAddress: validator.voteAccountAddress,
stakeAddress: stakeAccountAddress,
lamports: validator.activeStakeLamports.toNumber(),
});
}
const transientStakeAccountAddress = await findTransientStakeProgramAddress(
STAKE_POOL_PROGRAM_ID,
validator.voteAccountAddress,
stakePoolAddress,
validator.transientSeedSuffixStart!,
);
if (!validator.transientStakeLamports?.isZero()) {
accounts.push({
type: 'transient',
voteAddress: validator.voteAccountAddress,
stakeAddress: transientStakeAccountAddress,
lamports: validator.transientStakeLamports!.toNumber(),
});
}
}
// Sort from highest to lowest balance
accounts = accounts.sort((a, b) => b.lamports - a.lamports);
const reserveStake = await connection.getAccountInfo(stakePool.reserveStake);
if (reserveStake && reserveStake.lamports > 0) {
console.log('Reserve Stake: ', reserveStake.lamports);
accounts.push({
type: 'reserve',
stakeAddress: stakePool.reserveStake,
lamports: reserveStake?.lamports,
});
}
// Prepare the list of accounts to withdraw from
const withdrawFrom: WithdrawAccount[] = [];
let remainingAmount = amount;
for (const type of ['preferred', 'active', 'transient', 'reserve']) {
const filteredAccounts = accounts.filter(a => a.type == type);
for (const { stakeAddress, voteAddress, lamports } of filteredAccounts) {
let availableForWithdrawal = Math.floor(calcPoolTokensForDeposit(stakePool, lamports));
if (!stakePool.stakeWithdrawalFee.denominator.isZero()) {
availableForWithdrawal = divideBnToNumber(
new BN(availableForWithdrawal).mul(stakePool.stakeWithdrawalFee.denominator),
stakePool.stakeWithdrawalFee.denominator.sub(stakePool.stakeWithdrawalFee.numerator),
);
}
const poolAmount = Math.min(availableForWithdrawal, remainingAmount);
if (poolAmount <= 0) {
continue;
}
// Those accounts will be withdrawn completely with `claim` instruction
withdrawFrom.push({ stakeAddress, voteAddress, poolAmount });
remainingAmount -= poolAmount;
if (remainingAmount == 0) {
break;
}
}
if (remainingAmount == 0) {
break;
}
}
// Not enough stake to withdraw the specified amount
if (remainingAmount > 0) {
throw new Error(
`No stake accounts found in this pool with enough balance to withdraw ${lamportsToSol(amount)} pool tokens.`
);
}
return withdrawFrom;
}
/**
* Calculate the pool tokens that should be minted for a deposit of `stakeLamports`
*/
export function calcPoolTokensForDeposit(stakePool: StakePool, stakeLamports: number): number {
if (stakePool.poolTokenSupply.isZero() || stakePool.totalLamports.isZero()) {
return stakeLamports;
}
return divideBnToNumber(
new BN(stakeLamports).mul(stakePool.poolTokenSupply),
stakePool.totalLamports,
);
}
/**
* Calculate lamports amount on withdrawal
*/
export function calcLamportsWithdrawAmount(stakePool: StakePool, poolTokens: number): number {
const numerator = new BN(poolTokens).mul(stakePool.totalLamports);
const denominator = stakePool.poolTokenSupply;
if (numerator.lt(denominator)) {
return 0;
}
return divideBnToNumber(numerator, denominator);
}
export function divideBnToNumber(numerator: BN, denominator: BN): number {
if (denominator.isZero()) {
return 0;
}
const quotient = numerator.div(denominator).toNumber();
const rem = numerator.umod(denominator);
const gcd = rem.gcd(denominator);
return quotient + rem.div(gcd).toNumber() / denominator.div(gcd).toNumber();
}
export function newStakeAccount(
feePayer: PublicKey,
instructions: TransactionInstruction[],
lamports: number,
): Keypair {
// Account for tokens not specified, creating one
const stakeReceiverKeypair = Keypair.generate();
console.log(`Creating account to receive stake ${stakeReceiverKeypair.publicKey}`);
instructions.push(
// Creating new account
SystemProgram.createAccount({
fromPubkey: feePayer,
newAccountPubkey: stakeReceiverKeypair.publicKey,
lamports,
space: StakeProgram.space,
programId: StakeProgram.programId,
}),
);
return stakeReceiverKeypair;
}

View File

@ -0,0 +1,86 @@
import { Connection, PublicKey, TransactionInstruction } from "@solana/web3.js";
import {
AccountInfo,
ASSOCIATED_TOKEN_PROGRAM_ID,
Token,
TOKEN_PROGRAM_ID
} from "@solana/spl-token";
import { AccountLayout } from "../layouts";
const FAILED_TO_FIND_ACCOUNT = 'Failed to find account';
const INVALID_ACCOUNT_OWNER = 'Invalid account owner';
/**
* Retrieve the associated account or create one if not found.
* This account may then be used as a `transfer()` or `approve()` destination
*/
export async function addAssociatedTokenAccount(
connection: Connection,
owner: PublicKey,
mint: PublicKey,
instructions: TransactionInstruction[],
) {
const associatedAddress = await Token.getAssociatedTokenAddress(
ASSOCIATED_TOKEN_PROGRAM_ID,
TOKEN_PROGRAM_ID,
mint,
owner,
);
// This is the optimum logic, considering TX fee, client-side computation,
// RPC roundtrips and guaranteed idempotent.
// Sadly we can't do this atomically;
try {
const account = await connection.getAccountInfo(associatedAddress);
if (!account) {
// noinspection ExceptionCaughtLocallyJS
throw new Error(FAILED_TO_FIND_ACCOUNT);
}
} catch (err: any) {
// INVALID_ACCOUNT_OWNER can be possible if the associatedAddress has
// already been received some lamports (= became system accounts).
// Assuming program derived addressing is safe, this is the only case
// for the INVALID_ACCOUNT_OWNER in this code-path
if (err.message === FAILED_TO_FIND_ACCOUNT || err.message === INVALID_ACCOUNT_OWNER) {
instructions.push(
Token.createAssociatedTokenAccountInstruction(
ASSOCIATED_TOKEN_PROGRAM_ID,
TOKEN_PROGRAM_ID,
mint,
associatedAddress,
owner,
owner,
),
);
} else {
throw err;
}
console.warn(err);
}
return associatedAddress;
}
export async function getTokenAccount(
connection: Connection,
tokenAccountAddress: PublicKey,
expectedTokenMint: PublicKey,
): Promise<AccountInfo | void> {
try {
const account = await connection.getAccountInfo(tokenAccountAddress);
if (!account) {
// noinspection ExceptionCaughtLocallyJS
throw new Error(`Invalid account ${tokenAccountAddress.toBase58()}`);
}
const tokenAccount = AccountLayout.decode(account.data) as AccountInfo;
if (tokenAccount.mint?.toBase58() != expectedTokenMint.toBase58()) {
// noinspection ExceptionCaughtLocallyJS
throw new Error(
`Invalid token mint for ${tokenAccountAddress}, expected mint is ${expectedTokenMint}`,
);
}
return tokenAccount;
} catch (error) {
console.log(error);
}
}

View File

@ -0,0 +1,55 @@
import { Connection, PublicKey } from "@solana/web3.js";
import BN from "bn.js";
import { StakePoolAccount, getStakePoolAccounts } from "../src";
export function isStakePoolAccount(account: any): account is StakePoolAccount {
return (account !== undefined) &&
(account.account !== undefined) &&
(account.account.data !== undefined) &&
('manager' in account.account.data);
}
export async function getFirstStakePoolAccount(
connection: Connection,
stakePoolProgramAddress: PublicKey,
): Promise<StakePoolAccount | undefined> {
const accounts = await getStakePoolAccounts(connection, stakePoolProgramAddress);
return accounts!
.filter(account => isStakePoolAccount(account))
.pop() as StakePoolAccount;
}
/**
* Helper function to do deep equality check because BNs are not equal.
* TODO: write this function recursively. For now, sufficient.
*/
export function deepStrictEqualBN(a: any, b: any) {
for (const key in a) {
if (b[key] instanceof BN) {
expect(b[key].toString()).toEqual(a[key].toString());
} else {
if (a[key] instanceof Object) {
for (const subkey in a[key]) {
if (a[key][subkey] instanceof Object) {
if (a[key][subkey] instanceof BN) {
expect(b[key][subkey].toString()).toEqual(a[key][subkey].toString());
} else {
for (const subsubkey in a[key][subkey]) {
if (a[key][subkey][subsubkey] instanceof BN) {
expect(b[key][subkey][subsubkey].toString()).toEqual(a[key][subkey][subsubkey].toString());
} else {
expect(b[key][subkey][subsubkey]).toStrictEqual(a[key][subkey][subsubkey]);
}
}
}
} else {
expect(b[key][subkey]).toStrictEqual(a[key][subkey]);
}
}
} else {
expect(b[key]).toStrictEqual(a[key]);
}
}
}
}

View File

@ -0,0 +1,255 @@
import {
PublicKey,
Connection,
Keypair,
SystemProgram, AccountInfo, LAMPORTS_PER_SOL
} from '@solana/web3.js';
import { StakePoolLayout } from '../src/layouts';
import { STAKE_POOL_PROGRAM_ID } from '../src/constants';
import { decodeData } from '../src/copied-from-solana-web3/instruction';
import {
STAKE_POOL_INSTRUCTION_LAYOUTS,
DepositSolParams,
StakePoolInstruction,
depositSol,
withdrawSol,
withdrawStake
} from "../src";
import { mockTokenAccount, mockValidatorList, stakePoolMock } from "./mocks";
describe('StakePoolProgram', () => {
const connection = new Connection('http://127.0.0.1:8899');
connection.getMinimumBalanceForRentExemption = jest.fn(async () => 10000);
const stakePoolPubkey = new PublicKey(
'SPoo1Ku8WFXoNDMHPsrGSTSG1Y47rzgn41SLUNakuHy',
);
const data = Buffer.alloc(1024);
StakePoolLayout.encode(stakePoolMock, data)
const stakePoolAccount = <AccountInfo<any>>{
executable: true,
owner: stakePoolPubkey,
lamports: 99999,
data,
};
it('StakePoolInstruction.depositSol', () => {
const payload: DepositSolParams = {
stakePool: stakePoolPubkey,
withdrawAuthority: Keypair.generate().publicKey,
reserveStake: Keypair.generate().publicKey,
fundingAccount: Keypair.generate().publicKey,
destinationPoolAccount: Keypair.generate().publicKey,
managerFeeAccount: Keypair.generate().publicKey,
referralPoolAccount: Keypair.generate().publicKey,
poolMint: Keypair.generate().publicKey,
lamports: 99999,
};
const instruction = StakePoolInstruction.depositSol(payload);
expect(instruction.keys).toHaveLength(10);
expect(instruction.keys[0].pubkey.toBase58()).toEqual(payload.stakePool.toBase58());
expect(instruction.keys[1].pubkey.toBase58()).toEqual(payload.withdrawAuthority.toBase58());
expect(instruction.keys[3].pubkey.toBase58()).toEqual(payload.fundingAccount.toBase58());
expect(instruction.keys[4].pubkey.toBase58()).toEqual(payload.destinationPoolAccount.toBase58());
expect(instruction.keys[5].pubkey.toBase58()).toEqual(payload.managerFeeAccount.toBase58());
expect(instruction.keys[6].pubkey.toBase58()).toEqual(payload.referralPoolAccount.toBase58());
expect(instruction.keys[8].pubkey.toBase58()).toEqual(SystemProgram.programId.toBase58());
expect(instruction.keys[9].pubkey.toBase58()).toEqual(STAKE_POOL_PROGRAM_ID.toBase58());
const decodedData = decodeData(STAKE_POOL_INSTRUCTION_LAYOUTS.DepositSol, instruction.data);
expect(decodedData.instruction).toEqual(STAKE_POOL_INSTRUCTION_LAYOUTS.DepositSol.index);
expect(decodedData.lamports).toEqual(payload.lamports);
payload.depositAuthority = Keypair.generate().publicKey;
const instruction2 = StakePoolInstruction.depositSol(payload);
expect(instruction2.keys).toHaveLength(11);
expect(instruction2.keys[10].pubkey.toBase58()).toEqual(payload.depositAuthority.toBase58());
});
describe('depositSol', () => {
const from = Keypair.generate().publicKey;
const balance = 10000;
connection.getBalance = jest.fn(async () => balance);
connection.getAccountInfo = jest.fn(async (pubKey) => {
if (pubKey == stakePoolPubkey) {
return stakePoolAccount
}
return <AccountInfo<any>>{
executable: true,
owner: from,
lamports: balance,
data: null,
}
});
it.only('should throw an error with invalid balance', async () => {
await expect(
depositSol(connection, stakePoolPubkey, from, balance + 1)
).rejects.toThrow(Error('Not enough SOL to deposit into pool. Maximum deposit amount is 0.00001 SOL.'));
});
it.only('should throw an error with invalid account', async () => {
connection.getAccountInfo = jest.fn(async () => null);
await expect(
depositSol(connection, stakePoolPubkey, from, balance)
).rejects.toThrow(Error('Invalid account'));
});
it.only('should call successfully', async () => {
connection.getAccountInfo = jest.fn(async (pubKey) => {
if (pubKey == stakePoolPubkey) {
return stakePoolAccount
}
return <AccountInfo<any>>{
executable: true,
owner: from,
lamports: balance,
data: null,
}
});
const res = await depositSol(connection, stakePoolPubkey, from, balance)
expect((connection.getAccountInfo as jest.Mock).mock.calls.length).toBe(2);
expect(res.instructions).toHaveLength(2);
expect(res.signers).toHaveLength(1);
});
});
describe('withdrawSol', () => {
const tokenOwner = new PublicKey(0);
const solReceiver = new PublicKey(1);
it.only('should throw an error with invalid stake pool account', async () => {
connection.getAccountInfo = jest.fn(async () => null);
await expect(
withdrawSol(connection, stakePoolPubkey, tokenOwner, solReceiver, 1)
).rejects.toThrowError('Invalid account');
});
it.only('should throw an error with invalid token account', async () => {
connection.getAccountInfo = jest.fn(async (pubKey: PublicKey) => {
if (pubKey == stakePoolPubkey) {
return stakePoolAccount
}
if (pubKey.toBase58() == '9q2rZU5RujvyD9dmYKhzJAZfG4aGBbvQ8rWY52jCNBai') {
return null
}
return null;
});
await expect(
withdrawSol(connection, stakePoolPubkey, tokenOwner, solReceiver, 1)
).rejects.toThrow(Error('Invalid token account'));
});
it.only('should throw an error with invalid token account balance', async () => {
connection.getAccountInfo = jest.fn(async (pubKey: PublicKey) => {
if (pubKey == stakePoolPubkey) {
return stakePoolAccount
}
if (pubKey.toBase58() == 'GQkqTamwqjaNDfsbNm7r3aXPJ4oTSqKC3d5t2PF9Smqd') {
return mockTokenAccount(0);
}
return null;
});
await expect(
withdrawSol(connection, stakePoolPubkey, tokenOwner, solReceiver, 1)
).rejects.toThrow(Error('Not enough token balance to withdraw 1 pool tokens.\n Maximum withdraw amount is 0 pool tokens.'));
});
it.only('should call successfully', async () => {
connection.getAccountInfo = jest.fn(async (pubKey: PublicKey) => {
if (pubKey == stakePoolPubkey) {
return stakePoolAccount
}
if (pubKey.toBase58() == 'GQkqTamwqjaNDfsbNm7r3aXPJ4oTSqKC3d5t2PF9Smqd') {
return mockTokenAccount(LAMPORTS_PER_SOL);
}
return null;
});
const res = await withdrawSol(connection, stakePoolPubkey, tokenOwner, solReceiver, 1)
expect((connection.getAccountInfo as jest.Mock).mock.calls.length).toBe(2);
expect(res.instructions).toHaveLength(2);
expect(res.signers).toHaveLength(1);
});
})
describe('withdrawStake', () => {
const tokenOwner = new PublicKey(0);
it.only('should throw an error with invalid token account', async () => {
connection.getAccountInfo = jest.fn(async (pubKey: PublicKey) => {
if (pubKey == stakePoolPubkey) {
return stakePoolAccount
}
return null;
});
await expect(
withdrawStake(connection, stakePoolPubkey, tokenOwner, 1)
).rejects.toThrow(Error('Invalid token account'));
});
it.only('should throw an error with invalid token account balance', async () => {
connection.getAccountInfo = jest.fn(async (pubKey: PublicKey) => {
if (pubKey == stakePoolPubkey) {
return stakePoolAccount
}
if (pubKey.toBase58() == 'GQkqTamwqjaNDfsbNm7r3aXPJ4oTSqKC3d5t2PF9Smqd') {
return mockTokenAccount(0);
}
return null;
});
await expect(
withdrawStake(connection, stakePoolPubkey, tokenOwner, 1)
).rejects.toThrow(Error('Not enough token balance to withdraw 1 pool tokens.\n' +
' Maximum withdraw amount is 0 pool tokens.'));
});
it.only('should call successfully', async () => {
connection.getAccountInfo = jest.fn(async (pubKey: PublicKey) => {
if (pubKey == stakePoolPubkey) {
return stakePoolAccount
}
if (pubKey.toBase58() == 'GQkqTamwqjaNDfsbNm7r3aXPJ4oTSqKC3d5t2PF9Smqd') {
return mockTokenAccount(LAMPORTS_PER_SOL * 2);
}
if (pubKey.toBase58() == stakePoolMock.validatorList.toBase58()) {
return mockValidatorList();
}
return null;
});
const res = await withdrawStake(connection, stakePoolPubkey, tokenOwner, 1);
expect((connection.getAccountInfo as jest.Mock).mock.calls.length).toBe(4);
expect(res.instructions).toHaveLength(3);
expect(res.signers).toHaveLength(2);
expect(res.stakeReceiver).toEqual(undefined);
expect(res.totalRentFreeBalances).toEqual(10000);
});
});
});

View File

@ -0,0 +1,16 @@
/**
* TODO: https://github.com/solana-labs/solana-program-library/pull/2604
* @joncinque
* These tests could be extremely flaky because of the devnet connection, so we could probably just remove them.
* It doesn't need to be done in this PR, but eventually we should have tests that create a stake pool / deposit / withdraw,
* all only accessing a local test validator. Same as with the token and token-swap js tests.
*/
// @ts-ignore
describe('Integration test', () => {
it('should be implemented', () => {
})
});

View File

@ -0,0 +1,40 @@
import {
StakePoolLayout,
ValidatorListLayout,
ValidatorList,
} from '../src/layouts';
import { deepStrictEqualBN } from "./equal";
import { stakePoolMock, validatorListMock } from "./mocks";
describe('layouts', () => {
describe('StakePoolAccount', () => {
it('should successfully decode StakePoolAccount data', () => {
const encodedData = Buffer.alloc(1024);
StakePoolLayout.encode(stakePoolMock, encodedData);
const decodedData = StakePoolLayout.decode(encodedData);
deepStrictEqualBN(decodedData, stakePoolMock);
});
});
describe('ValidatorListAccount', () => {
it('should successfully decode ValidatorListAccount account data', () => {
const expectedData: ValidatorList = {
accountType: 0,
maxValidators: 10,
validators: [],
};
const encodedData = Buffer.alloc(64);
ValidatorListLayout.encode(expectedData, encodedData);
const decodedData = ValidatorListLayout.decode(encodedData);
expect(decodedData).toEqual(expectedData);
});
it('should successfully decode ValidatorListAccount with nonempty ValidatorInfo', () => {
const encodedData = Buffer.alloc(1024);
ValidatorListLayout.encode(validatorListMock, encodedData);
const decodedData = ValidatorListLayout.decode(encodedData);
deepStrictEqualBN(decodedData, validatorListMock);
});
});
});

150
stake-pool/js/test/mocks.ts Normal file
View File

@ -0,0 +1,150 @@
import { AccountInfo, LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js";
import BN from "bn.js";
import { ValidatorStakeInfo } from "../src";
import { ValidatorStakeInfoStatus, AccountLayout, ValidatorListLayout } from "../src/layouts";
export const stakePoolMock = {
accountType: 1,
manager: new PublicKey(11),
staker: new PublicKey(12),
stakeDepositAuthority: new PublicKey(13),
stakeWithdrawBumpSeed: 255,
validatorList: new PublicKey(14),
reserveStake: new PublicKey(15),
poolMint: new PublicKey(16),
managerFeeAccount: new PublicKey(17),
tokenProgramId: new PublicKey(18),
totalLamports: new BN(LAMPORTS_PER_SOL * 999),
poolTokenSupply: new BN(LAMPORTS_PER_SOL * 100),
lastUpdateEpoch: new BN('7c', 'hex'),
lockup: {
unixTimestamp: new BN(Date.now()),
epoch: new BN(1),
custodian: new PublicKey(0),
},
epochFee: {
denominator: new BN(0),
numerator: new BN(0),
},
nextEpochFee: {
denominator: new BN(0),
numerator: new BN(0),
},
preferredDepositValidatorVoteAddress: new PublicKey(1),
preferredWithdrawValidatorVoteAddress: new PublicKey(2),
stakeDepositFee: {
denominator: new BN(0),
numerator: new BN(0),
},
stakeWithdrawalFee: {
denominator: new BN(0),
numerator: new BN(0),
},
nextWithdrawalFee: {
denominator: new BN(0),
numerator: new BN(0),
},
stakeReferralFee: 0,
solDepositAuthority: new PublicKey(0),
solDepositFee: {
denominator: new BN(0),
numerator: new BN(0),
},
solReferralFee: 0,
solWithdrawAuthority: new PublicKey(0),
solWithdrawalFee: {
denominator: new BN(0),
numerator: new BN(0),
},
nextSolWithdrawalFee: {
denominator: new BN(0),
numerator: new BN(0),
},
lastEpochPoolTokenSupply: new BN(0),
lastEpochTotalLamports: new BN(0),
};
export const validatorListMock = {
accountType: 0,
maxValidators: 100,
validators: <ValidatorStakeInfo[]>[
{
status: ValidatorStakeInfoStatus.ReadyForRemoval,
voteAccountAddress: new PublicKey(
new BN(
'a9946a889af14fd3c9b33d5df309489d9699271a6b09ff3190fcb41cf21a2f8c',
'hex',
),
),
lastUpdateEpoch: new BN('c3', 'hex'),
activeStakeLamports: new BN(123),
transientStakeLamports: new BN(999),
transientSeedSuffixStart: new BN(999),
transientSeedSuffixEnd: new BN(999),
},
{
status: ValidatorStakeInfoStatus.Active,
voteAccountAddress: new PublicKey(
new BN(
'3796d40645ee07e3c64117e3f73430471d4c40465f696ebc9b034c1fc06a9f7d',
'hex',
),
),
lastUpdateEpoch: new BN('c3', 'hex'),
activeStakeLamports: new BN(LAMPORTS_PER_SOL * 100),
transientStakeLamports: new BN(22),
transientSeedSuffixStart: new BN(0),
transientSeedSuffixEnd: new BN(0),
},
{
status: ValidatorStakeInfoStatus.Active,
voteAccountAddress: new PublicKey(
new BN(
'e4e37d6f2e80c0bb0f3da8a06304e57be5cda6efa2825b86780aa320d9784cf8',
'hex',
),
),
lastUpdateEpoch: new BN('c3', 'hex'),
activeStakeLamports: new BN(0),
transientStakeLamports: new BN(0),
transientSeedSuffixStart: new BN('a', 'hex'),
transientSeedSuffixEnd: new BN('a', 'hex'),
},
],
}
export function mockTokenAccount(amount = 0) {
const data = Buffer.alloc(1024);
AccountLayout.encode({
state: 0,
mint: stakePoolMock.poolMint,
owner: new PublicKey(0),
amount: new BN(amount),
// address: new PublicKey(0),
// delegate: null,
// delegatedAmount: new BN(0),
// isInitialized: true,
// isFrozen: false,
// isNative: false,
// rentExemptReserve: null,
// closeAuthority: null,
}, data)
return <AccountInfo<any>>{
executable: true,
owner: new PublicKey(0),
lamports: amount,
data,
}
}
export function mockValidatorList() {
const data = Buffer.alloc(1024);
ValidatorListLayout.encode(validatorListMock, data)
return <AccountInfo<any>>{
executable: true,
owner: new PublicKey(0),
lamports: 0,
data,
}
}

View File

@ -1,14 +1,23 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES6", "target": "es2019",
"module": "ES6", "allowJs": true,
"esModuleInterop": true, "declaration": true,
"moduleResolution": "Node", "declarationDir": "declarations",
"outDir": "dist", "esModuleInterop": true,
"declaration": true, "allowSyntheticDefaultImports": true,
"declarationMap": true, "strict": true,
}, "forceConsistentCasingInFileNames": true,
"include": [ "module": "esnext",
"src/**/*.ts" "moduleResolution": "node",
] "resolveJsonModule": true,
} "isolatedModules": true,
"baseUrl": "src",
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true
},
"include": [
"test",
"src"
]
}