diff --git a/stake-pool/js/README.md b/stake-pool/js/README.md index b4030d23..947ec739 100644 --- a/stake-pool/js/README.md +++ b/stake-pool/js/README.md @@ -51,4 +51,4 @@ console.log(solanaStakePool); ```javascript // `solanaStakePool` is provided in the global namespace by the script bundle. console.log(solanaStakePool); -``` \ No newline at end of file +``` diff --git a/stake-pool/js/src/constants.ts b/stake-pool/js/src/constants.ts index 447835fa..9423f7ce 100644 --- a/stake-pool/js/src/constants.ts +++ b/stake-pool/js/src/constants.ts @@ -7,6 +7,9 @@ export const STAKE_POOL_PROGRAM_ID = new PublicKey('SPoo1Ku8WFXoNDMHPsrGSTSG1Y47 // Maximum number of validators to update during UpdateValidatorListBalance. export const MAX_VALIDATORS_TO_UPDATE = 5; +// Seed for ephemeral stake account +export const EPHEMERAL_STAKE_SEED_PREFIX = Buffer.from('ephemeral'); + // Seed used to derive transient stake accounts. export const TRANSIENT_STAKE_SEED_PREFIX = Buffer.from('transient'); diff --git a/stake-pool/js/src/index.ts b/stake-pool/js/src/index.ts index 6cd146b3..d18495f4 100644 --- a/stake-pool/js/src/index.ts +++ b/stake-pool/js/src/index.ts @@ -24,6 +24,7 @@ import { prepareWithdrawAccounts, lamportsToSol, solToLamports, + findEphemeralStakeProgramAddress, } from './utils'; import { StakePoolInstruction } from './instructions'; import { @@ -36,6 +37,7 @@ import { } from './layouts'; import { MAX_VALIDATORS_TO_UPDATE, MINIMUM_ACTIVE_STAKE, STAKE_POOL_PROGRAM_ID } from './constants'; import { create } from 'superstruct'; +import BN from 'bn.js'; export type { StakePool, AccountType, ValidatorList, ValidatorStakeInfo } from './layouts'; export { STAKE_POOL_PROGRAM_ID } from './constants'; @@ -66,6 +68,17 @@ export interface StakePoolAccounts { validatorList: ValidatorListAccount | undefined; } +interface RedelegateProps { + connection: Connection; + stakePoolAddress: PublicKey; + sourceVoteAccount: PublicKey; + destinationVoteAccount: PublicKey; + sourceTransientStakeSeed: number | BN; + destinationTransientStakeSeed: number | BN; + ephemeralStakeSeed: number | BN; + lamports: number | BN; +} + /** * Retrieves and deserializes a StakePool account using a web3js connection and the stake pool address. * @param connection: An active web3js connection. @@ -401,7 +414,7 @@ export async function withdrawStake( val.voteAccountAddress.equals(voteAccount), ); if (voteAccountAddress && voteAccountAddress !== voteAccount) { - throw new Error(`Provided withdrawal vote account ${voteAccountAddress} does not match delegation on stake receiver account ${voteAccount}, + throw new Error(`Provided withdrawal vote account ${voteAccountAddress} does not match delegation on stake receiver account ${voteAccount}, remove this flag or provide a different stake account delegated to ${voteAccountAddress}`); } if (isValidVoter) { @@ -668,6 +681,7 @@ export async function increaseValidatorStake( stakePoolAddress: PublicKey, validatorVote: PublicKey, lamports: number, + ephemeralStakeSeed?: number, ) { const stakePool = await getStakePoolAccount(connection, stakePoolAddress); @@ -705,8 +719,14 @@ export async function increaseValidatorStake( ); const instructions: TransactionInstruction[] = []; - instructions.push( - StakePoolInstruction.increaseValidatorStake({ + + if (ephemeralStakeSeed != undefined) { + const ephemeralStake = await findEphemeralStakeProgramAddress( + STAKE_POOL_PROGRAM_ID, + stakePoolAddress, + new BN(ephemeralStakeSeed), + ); + StakePoolInstruction.increaseAdditionalValidatorStake({ stakePool: stakePoolAddress, staker: stakePool.account.data.staker, validatorList: stakePool.account.data.validatorList, @@ -717,8 +737,25 @@ export async function increaseValidatorStake( validatorStake, validatorVote, lamports, - }), - ); + ephemeralStake, + ephemeralStakeSeed, + }); + } else { + instructions.push( + StakePoolInstruction.increaseValidatorStake({ + stakePool: stakePoolAddress, + staker: stakePool.account.data.staker, + validatorList: stakePool.account.data.validatorList, + reserveStake: stakePool.account.data.reserveStake, + transientStakeSeed: transientStakeSeed.toNumber(), + withdrawAuthority, + transientStake, + validatorStake, + validatorVote, + lamports, + }), + ); + } return { instructions, @@ -733,6 +770,7 @@ export async function decreaseValidatorStake( stakePoolAddress: PublicKey, validatorVote: PublicKey, lamports: number, + ephemeralStakeSeed?: number, ) { const stakePool = await getStakePoolAccount(connection, stakePoolAddress); const validatorList = await getValidatorListAccount( @@ -769,18 +807,41 @@ export async function decreaseValidatorStake( ); const instructions: TransactionInstruction[] = []; - instructions.push( - StakePoolInstruction.decreaseValidatorStake({ - stakePool: stakePoolAddress, - staker: stakePool.account.data.staker, - validatorList: stakePool.account.data.validatorList, - transientStakeSeed: transientStakeSeed.toNumber(), - withdrawAuthority, - validatorStake, - transientStake, - lamports, - }), - ); + + if (ephemeralStakeSeed != undefined) { + const ephemeralStake = await findEphemeralStakeProgramAddress( + STAKE_POOL_PROGRAM_ID, + stakePoolAddress, + new BN(ephemeralStakeSeed), + ); + instructions.push( + StakePoolInstruction.decreaseAdditionalValidatorStake({ + stakePool: stakePoolAddress, + staker: stakePool.account.data.staker, + validatorList: stakePool.account.data.validatorList, + transientStakeSeed: transientStakeSeed.toNumber(), + withdrawAuthority, + validatorStake, + transientStake, + lamports, + ephemeralStake, + ephemeralStakeSeed, + }), + ); + } else { + instructions.push( + StakePoolInstruction.decreaseValidatorStake({ + stakePool: stakePoolAddress, + staker: stakePool.account.data.staker, + validatorList: stakePool.account.data.validatorList, + transientStakeSeed: transientStakeSeed.toNumber(), + withdrawAuthority, + validatorStake, + transientStake, + lamports, + }), + ); + } return { instructions, @@ -993,3 +1054,82 @@ export async function stakePoolInfo(connection: Connection, stakePoolAddress: Pu }, // CliStakePoolDetails }; } + +/** + * Creates instructions required to redelegate stake. + */ +export async function redelegate(props: RedelegateProps) { + const { + connection, + stakePoolAddress, + sourceVoteAccount, + sourceTransientStakeSeed, + destinationVoteAccount, + destinationTransientStakeSeed, + ephemeralStakeSeed, + lamports, + } = props; + const stakePool = await getStakePoolAccount(connection, stakePoolAddress); + + const stakePoolWithdrawAuthority = await findWithdrawAuthorityProgramAddress( + STAKE_POOL_PROGRAM_ID, + stakePoolAddress, + ); + + const sourceValidatorStake = await findStakeProgramAddress( + STAKE_POOL_PROGRAM_ID, + sourceVoteAccount, + stakePoolAddress, + ); + + const sourceTransientStake = await findTransientStakeProgramAddress( + STAKE_POOL_PROGRAM_ID, + sourceVoteAccount, + stakePoolAddress, + new BN(sourceTransientStakeSeed), + ); + + const destinationValidatorStake = await findStakeProgramAddress( + STAKE_POOL_PROGRAM_ID, + destinationVoteAccount, + stakePoolAddress, + ); + + const destinationTransientStake = await findTransientStakeProgramAddress( + STAKE_POOL_PROGRAM_ID, + destinationVoteAccount, + stakePoolAddress, + new BN(destinationTransientStakeSeed), + ); + + const ephemeralStake = await findEphemeralStakeProgramAddress( + STAKE_POOL_PROGRAM_ID, + stakePoolAddress, + new BN(ephemeralStakeSeed), + ); + + const instructions: TransactionInstruction[] = []; + + instructions.push( + StakePoolInstruction.redelegate({ + stakePool: stakePool.pubkey, + staker: stakePool.account.data.staker, + validatorList: stakePool.account.data.validatorList, + stakePoolWithdrawAuthority, + ephemeralStake, + ephemeralStakeSeed, + sourceValidatorStake, + sourceTransientStake, + sourceTransientStakeSeed, + destinationValidatorStake, + destinationTransientStake, + destinationTransientStakeSeed, + validator: destinationVoteAccount, + lamports, + }), + ); + + return { + instructions, + }; +} diff --git a/stake-pool/js/src/instructions.ts b/stake-pool/js/src/instructions.ts index babcc2cd..bfc35f38 100644 --- a/stake-pool/js/src/instructions.ts +++ b/stake-pool/js/src/instructions.ts @@ -12,6 +12,7 @@ import * as BufferLayout from '@solana/buffer-layout'; import { TOKEN_PROGRAM_ID } from '@solana/spl-token'; import { STAKE_POOL_PROGRAM_ID } from './constants'; import { InstructionType, encodeData, decodeData } from './utils'; +import BN from 'bn.js'; /** * An enumeration of valid StakePoolInstructionType's @@ -25,7 +26,10 @@ export type StakePoolInstructionType = | 'DepositStake' | 'DepositSol' | 'WithdrawStake' - | 'WithdrawSol'; + | 'WithdrawSol' + | 'IncreaseAdditionalValidatorStake' + | 'DecreaseAdditionalValidatorStake' + | 'Redelegate'; const MOVE_STAKE_LAYOUT = BufferLayout.struct([ BufferLayout.u8('instruction'), @@ -96,6 +100,40 @@ export const STAKE_POOL_INSTRUCTION_LAYOUTS: { BufferLayout.ns64('poolTokens'), ]), }, + IncreaseAdditionalValidatorStake: { + index: 19, + layout: BufferLayout.struct([ + BufferLayout.u8('instruction'), + BufferLayout.ns64('lamports'), + BufferLayout.ns64('transientStakeSeed'), + BufferLayout.ns64('ephemeralStakeSeed'), + ]), + }, + DecreaseAdditionalValidatorStake: { + index: 20, + layout: BufferLayout.struct([ + BufferLayout.u8('instruction'), + BufferLayout.ns64('lamports'), + BufferLayout.ns64('transientStakeSeed'), + BufferLayout.ns64('ephemeralStakeSeed'), + ]), + }, + Redelegate: { + index: 21, + layout: BufferLayout.struct([ + BufferLayout.u8('instruction'), + /// Amount of lamports to redelegate + BufferLayout.ns64('lamports'), + /// Seed used to create source transient stake account + BufferLayout.ns64('sourceTransientStakeSeed'), + /// Seed used to create destination ephemeral account. + BufferLayout.ns64('ephemeralStakeSeed'), + /// Seed used to create destination transient stake account. If there is + /// already transient stake, this must match the current seed, otherwise + /// it can be anything + BufferLayout.ns64('destinationTransientStakeSeed'), + ]), + }, }); /** @@ -141,12 +179,17 @@ export type DecreaseValidatorStakeParams = { validatorList: PublicKey; validatorStake: PublicKey; transientStake: PublicKey; - // Amount of lamports to split into the transient stake account. + // Amount of lamports to split into the transient stake account lamports: number; - // Seed to used to create the transient stake account. + // Seed to used to create the transient stake account transientStakeSeed: number; }; +export interface DecreaseAdditionalValidatorStakeParams extends DecreaseValidatorStakeParams { + ephemeralStake: PublicKey; + ephemeralStakeSeed: number; +} + /** * (Staker only) Increase stake on a validator from the reserve account. */ @@ -159,12 +202,17 @@ export type IncreaseValidatorStakeParams = { transientStake: PublicKey; validatorStake: PublicKey; validatorVote: PublicKey; - // Amount of lamports to split into the transient stake account. + // Amount of lamports to split into the transient stake account lamports: number; - // Seed to used to create the transient stake account. + // Seed to used to create the transient stake account transientStakeSeed: number; }; +export interface IncreaseAdditionalValidatorStakeParams extends IncreaseValidatorStakeParams { + ephemeralStake: PublicKey; + ephemeralStakeSeed: number; +} + /** * Deposits a stake account into the pool in exchange for pool tokens */ @@ -232,6 +280,29 @@ export type DepositSolParams = { lamports: number; }; +export type RedelegateParams = { + stakePool: PublicKey; + staker: PublicKey; + stakePoolWithdrawAuthority: PublicKey; + validatorList: PublicKey; + sourceValidatorStake: PublicKey; + sourceTransientStake: PublicKey; + ephemeralStake: PublicKey; + destinationTransientStake: PublicKey; + destinationValidatorStake: PublicKey; + validator: PublicKey; + // Amount of lamports to redelegate + lamports: number | BN; + // Seed used to create source transient stake account + sourceTransientStakeSeed: number | BN; + // Seed used to create destination ephemeral account + ephemeralStakeSeed: number | BN; + // Seed used to create destination transient stake account. If there is + // already transient stake, this must match the current seed, otherwise + // it can be anything + destinationTransientStakeSeed: number | BN; +}; + /** * Stake Pool Instruction class */ @@ -334,7 +405,8 @@ export class StakePoolInstruction { } /** - * Creates instruction to increase the stake on a validator. + * Creates `IncreaseValidatorStake` instruction (rebalance from reserve account to + * transient account) */ static increaseValidatorStake(params: IncreaseValidatorStakeParams): TransactionInstruction { const { @@ -378,7 +450,57 @@ export class StakePoolInstruction { } /** - * Creates instruction to decrease the stake on a validator. + * Creates `IncreaseAdditionalValidatorStake` instruction (rebalance from reserve account to + * transient account) + */ + static increaseAdditionalValidatorStake( + params: IncreaseAdditionalValidatorStakeParams, + ): TransactionInstruction { + const { + stakePool, + staker, + withdrawAuthority, + validatorList, + reserveStake, + transientStake, + validatorStake, + validatorVote, + lamports, + transientStakeSeed, + ephemeralStake, + ephemeralStakeSeed, + } = params; + + const type = STAKE_POOL_INSTRUCTION_LAYOUTS.IncreaseAdditionalValidatorStake; + const data = encodeData(type, { lamports, transientStakeSeed, ephemeralStakeSeed }); + + const keys = [ + { pubkey: stakePool, isSigner: false, isWritable: false }, + { pubkey: staker, isSigner: true, isWritable: false }, + { pubkey: withdrawAuthority, isSigner: false, isWritable: false }, + { pubkey: validatorList, isSigner: false, isWritable: true }, + { pubkey: reserveStake, isSigner: false, isWritable: true }, + { pubkey: ephemeralStake, isSigner: false, isWritable: true }, + { pubkey: transientStake, isSigner: false, isWritable: true }, + { pubkey: validatorStake, isSigner: false, isWritable: false }, + { pubkey: validatorVote, isSigner: false, isWritable: false }, + { pubkey: SYSVAR_CLOCK_PUBKEY, isSigner: false, isWritable: false }, + { pubkey: SYSVAR_STAKE_HISTORY_PUBKEY, isSigner: false, isWritable: false }, + { pubkey: STAKE_CONFIG_ID, isSigner: false, isWritable: false }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + { pubkey: StakeProgram.programId, isSigner: false, isWritable: false }, + ]; + + return new TransactionInstruction({ + programId: STAKE_POOL_PROGRAM_ID, + keys, + data, + }); + } + + /** + * Creates `DecreaseValidatorStake` instruction (rebalance from validator account to + * transient account) */ static decreaseValidatorStake(params: DecreaseValidatorStakeParams): TransactionInstruction { const { @@ -415,6 +537,50 @@ export class StakePoolInstruction { }); } + /** + * Creates `DecreaseAdditionalValidatorStake` instruction (rebalance from + * validator account to transient account) + */ + static decreaseAdditionalValidatorStake( + params: DecreaseAdditionalValidatorStakeParams, + ): TransactionInstruction { + const { + stakePool, + staker, + withdrawAuthority, + validatorList, + validatorStake, + transientStake, + lamports, + transientStakeSeed, + ephemeralStakeSeed, + ephemeralStake, + } = params; + + const type = STAKE_POOL_INSTRUCTION_LAYOUTS.DecreaseAdditionalValidatorStake; + const data = encodeData(type, { lamports, transientStakeSeed, ephemeralStakeSeed }); + + const keys = [ + { pubkey: stakePool, isSigner: false, isWritable: false }, + { pubkey: staker, isSigner: true, isWritable: false }, + { pubkey: withdrawAuthority, isSigner: false, isWritable: false }, + { pubkey: validatorList, isSigner: false, isWritable: true }, + { pubkey: validatorStake, isSigner: false, isWritable: true }, + { pubkey: ephemeralStake, isSigner: false, isWritable: true }, + { pubkey: transientStake, isSigner: false, isWritable: true }, + { pubkey: SYSVAR_CLOCK_PUBKEY, isSigner: false, isWritable: false }, + { pubkey: SYSVAR_STAKE_HISTORY_PUBKEY, isSigner: false, isWritable: false }, + { pubkey: SystemProgram.programId, 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 deposit a stake account into a stake pool. */ @@ -603,6 +769,60 @@ export class StakePoolInstruction { }); } + /** + * Creates `Redelegate` instruction (rebalance from one validator account to another) + * @param params + */ + static redelegate(params: RedelegateParams): TransactionInstruction { + const { + stakePool, + staker, + stakePoolWithdrawAuthority, + validatorList, + sourceValidatorStake, + sourceTransientStake, + ephemeralStake, + destinationTransientStake, + destinationValidatorStake, + validator, + lamports, + sourceTransientStakeSeed, + ephemeralStakeSeed, + destinationTransientStakeSeed, + } = params; + + const keys = [ + { pubkey: stakePool, isSigner: false, isWritable: false }, + { pubkey: staker, isSigner: true, isWritable: false }, + { pubkey: stakePoolWithdrawAuthority, isSigner: false, isWritable: false }, + { pubkey: validatorList, isSigner: false, isWritable: true }, + { pubkey: sourceValidatorStake, isSigner: false, isWritable: true }, + { pubkey: sourceTransientStake, isSigner: false, isWritable: true }, + { pubkey: ephemeralStake, isSigner: false, isWritable: true }, + { pubkey: destinationTransientStake, isSigner: false, isWritable: true }, + { pubkey: destinationValidatorStake, isSigner: false, isWritable: false }, + { pubkey: validator, isSigner: false, isWritable: false }, + { pubkey: SYSVAR_CLOCK_PUBKEY, isSigner: false, isWritable: false }, + { pubkey: SYSVAR_STAKE_HISTORY_PUBKEY, isSigner: false, isWritable: false }, + { pubkey: STAKE_CONFIG_ID, isSigner: false, isWritable: false }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + { pubkey: StakeProgram.programId, isSigner: false, isWritable: false }, + ]; + + const data = encodeData(STAKE_POOL_INSTRUCTION_LAYOUTS.Redelegate, { + lamports, + sourceTransientStakeSeed, + ephemeralStakeSeed, + destinationTransientStakeSeed, + }); + + return new TransactionInstruction({ + programId: STAKE_POOL_PROGRAM_ID, + keys, + data, + }); + } + /** * Decode a deposit stake pool instruction and retrieve the instruction params. */ diff --git a/stake-pool/js/src/utils/program-address.ts b/stake-pool/js/src/utils/program-address.ts index 2eb364c1..7d198235 100644 --- a/stake-pool/js/src/utils/program-address.ts +++ b/stake-pool/js/src/utils/program-address.ts @@ -1,7 +1,7 @@ import { PublicKey } from '@solana/web3.js'; import BN from 'bn.js'; import { Buffer } from 'buffer'; -import { TRANSIENT_STAKE_SEED_PREFIX } from '../constants'; +import { EPHEMERAL_STAKE_SEED_PREFIX, TRANSIENT_STAKE_SEED_PREFIX } from '../constants'; /** * Generates the withdraw authority program address for the stake pool @@ -46,9 +46,24 @@ export async function findTransientStakeProgramAddress( TRANSIENT_STAKE_SEED_PREFIX, voteAccountAddress.toBuffer(), stakePoolAddress.toBuffer(), - new Uint8Array(seed.toArray('le', 8)), + seed.toBuffer('le', 8), ], programId, ); return publicKey; } + +/** + * Generates the ephemeral program address for stake pool redelegation + */ +export async function findEphemeralStakeProgramAddress( + programId: PublicKey, + stakePoolAddress: PublicKey, + seed: BN, +) { + const [publicKey] = await PublicKey.findProgramAddress( + [EPHEMERAL_STAKE_SEED_PREFIX, stakePoolAddress.toBuffer(), seed.toBuffer('le', 8)], + programId, + ); + return publicKey; +} diff --git a/stake-pool/js/test/instructions.test.ts b/stake-pool/js/test/instructions.test.ts index 458eb0cb..204d85d5 100644 --- a/stake-pool/js/test/instructions.test.ts +++ b/stake-pool/js/test/instructions.test.ts @@ -15,6 +15,7 @@ import { depositSol, withdrawSol, withdrawStake, + redelegate, getStakeAccount, } from '../src'; @@ -317,4 +318,30 @@ describe('StakePoolProgram', () => { expect(parsedStakeAccount).toEqual(uninitializedStakeAccount.parsed); }); }); + + describe('redelegation', () => { + it.only('should call successfully', async () => { + const data = { + connection, + stakePoolAddress, + sourceVoteAccount: PublicKey.default, + sourceTransientStakeSeed: 10, + destinationVoteAccount: PublicKey.default, + destinationTransientStakeSeed: 20, + ephemeralStakeSeed: 100, + lamports: 100, + }; + const res = await redelegate(data); + + const decodedData = STAKE_POOL_INSTRUCTION_LAYOUTS.Redelegate.layout.decode( + res.instructions[0].data, + ); + + expect(decodedData.instruction).toBe(21); + expect(decodedData.lamports).toBe(data.lamports); + expect(decodedData.sourceTransientStakeSeed).toBe(data.sourceTransientStakeSeed); + expect(decodedData.destinationTransientStakeSeed).toBe(data.destinationTransientStakeSeed); + expect(decodedData.ephemeralStakeSeed).toBe(data.ephemeralStakeSeed); + }); + }); }); diff --git a/stake-pool/js/test/mocks.ts b/stake-pool/js/test/mocks.ts index db93fa5a..dd123dc4 100644 --- a/stake-pool/js/test/mocks.ts +++ b/stake-pool/js/test/mocks.ts @@ -1,7 +1,7 @@ import { AccountInfo, LAMPORTS_PER_SOL, PublicKey, StakeProgram } from '@solana/web3.js'; import BN from 'bn.js'; import { ValidatorStakeInfo } from '../src'; -import { ValidatorStakeInfoStatus, AccountLayout, ValidatorListLayout } from '../src/layouts'; +import { AccountLayout, ValidatorListLayout, ValidatorStakeInfoStatus } from '../src/layouts'; export const CONSTANTS = { poolTokenAccount: new PublicKey( @@ -149,13 +149,12 @@ export const mockRpc = (data: any): any => { executable: false, rentEpoch: 0, }; - const result = { + return { context: { slot: 11, }, value: value, }; - return result; }; export const stakeAccountData = {