Repository: solana-labs/solana-program-library
Pull request #6459 (2024-03-19 16:49:28)

stake-pool: supporting AddValidatorToPool in js library
This PR adds support for the `AddValidatorToPool` instruction for the stake pool program in the javascript library. Removing a validator has not been implemented in the spirit of keeping the PR as small as possible. If accepted, I can also implement support for that instruction as well.

My guess is that this instruction was never prioritized in the javascript library due to the `spl-stake-pool` package mostly being used in frontend applications. This instruction is helpful to have though for users who are using nodejs, hence why I added support for it.

The code was successfully run on mainnet with tx hash `4QnuyTWTYYTPX1pr8sw5haFUp96PnNXCe4VWbWZVzN9PcRvhDkTaGpxKvpRC2wETC23VrgoSBdDC1UhZ7s3vfoM6` targetting Edgevana's stake pool.

kaplanmaxe: Thanks a lot for your review and commenets @buffalojoec ! I greatly appreciate it. I've addressed your comments and squashed my commits

Patch:
diff --git a/stake-pool/js/src/index.ts b/stake-pool/js/src/index.ts
index 307918423..4b4981944 100644
--- a/stake-pool/js/src/index.ts
+++ b/stake-pool/js/src/index.ts
@@ -649,6 +649,55 @@ export async function withdrawSol(
   };
 }
 
+export async function addValidatorToPool(
+  connection: Connection,
+  stakePoolAddress: PublicKey,
+  validatorVote: PublicKey,
+  seed?: number,
+) {
+  const stakePoolAccount = await getStakePoolAccount(connection, stakePoolAddress);
+  const stakePool = stakePoolAccount.account.data;
+  const { reserveStake, staker, validatorList } = stakePool;
+
+  const validatorListAccount = await getValidatorListAccount(connection, validatorList);
+
+  const validatorInfo = validatorListAccount.account.data.validators.find(
+    (v) => v.voteAccountAddress.toBase58() == validatorVote.toBase58(),
+  );
+
+  if (validatorInfo) {
+    throw new Error('Vote account is already in validator list');
+  }
+
+  const withdrawAuthority = await findWithdrawAuthorityProgramAddress(
+    STAKE_POOL_PROGRAM_ID,
+    stakePoolAddress,
+  );
+
+  const validatorStake = await findStakeProgramAddress(
+    STAKE_POOL_PROGRAM_ID,
+    validatorVote,
+    stakePoolAddress,
+    seed,
+  );
+
+  const instructions: TransactionInstruction[] = [
+    StakePoolInstruction.addValidatorToPool({
+      stakePool: stakePoolAddress,
+      staker: staker,
+      reserveStake: reserveStake,
+      withdrawAuthority: withdrawAuthority,
+      validatorList: validatorList,
+      validatorStake: validatorStake,
+      validatorVote: validatorVote,
+    }),
+  ];
+
+  return {
+    instructions,
+  };
+}
+
 /**
  * Creates instructions required to increase validator stake.
  */
diff --git a/stake-pool/js/src/instructions.ts b/stake-pool/js/src/instructions.ts
index 9891f17b6..d758a3c92 100644
--- a/stake-pool/js/src/instructions.ts
+++ b/stake-pool/js/src/instructions.ts
@@ -36,7 +36,8 @@ export type StakePoolInstructionType =
   | 'IncreaseAdditionalValidatorStake'
   | 'DecreaseAdditionalValidatorStake'
   | 'DecreaseValidatorStakeWithReserve'
-  | 'Redelegate';
+  | 'Redelegate'
+  | 'AddValidatorToPool';
 
 // 'UpdateTokenMetadata' and 'CreateTokenMetadata' have dynamic layouts
 
@@ -91,6 +92,10 @@ export function tokenMetadataLayout(
 export const STAKE_POOL_INSTRUCTION_LAYOUTS: {
   [type in StakePoolInstructionType]: InstructionType;
 } = Object.freeze({
+  AddValidatorToPool: {
+    index: 1,
+    layout: BufferLayout.struct<any>([BufferLayout.u8('instruction'), BufferLayout.u32('seed')]),
+  },
   DecreaseValidatorStake: {
     index: 3,
     layout: MOVE_STAKE_LAYOUT,
@@ -376,10 +381,61 @@ export type UpdateTokenMetadataParams = {
   uri: string;
 };
 
+export type AddValidatorToPoolParams = {
+  stakePool: PublicKey;
+  staker: PublicKey;
+  reserveStake: PublicKey;
+  withdrawAuthority: PublicKey;
+  validatorList: PublicKey;
+  validatorStake: PublicKey;
+  validatorVote: PublicKey;
+  seed?: number;
+};
+
 /**
  * Stake Pool Instruction class
  */
 export class StakePoolInstruction {
+  /**
+   * Creates instruction to add a validator into the stake pool.
+   */
+  static addValidatorToPool(params: AddValidatorToPoolParams): TransactionInstruction {
+    const {
+      stakePool,
+      staker,
+      reserveStake,
+      withdrawAuthority,
+      validatorList,
+      validatorStake,
+      validatorVote,
+      seed,
+    } = params;
+    const type = STAKE_POOL_INSTRUCTION_LAYOUTS.AddValidatorToPool;
+    const data = encodeData(type, { seed: seed == undefined ? 0 : seed });
+
+    const keys = [
+      { pubkey: stakePool, isSigner: false, isWritable: true },
+      { pubkey: staker, isSigner: true, isWritable: false },
+      { pubkey: reserveStake, isSigner: false, isWritable: true },
+      { pubkey: withdrawAuthority, isSigner: false, isWritable: false },
+      { pubkey: validatorList, isSigner: false, isWritable: true },
+      { pubkey: validatorStake, isSigner: false, isWritable: true },
+      { pubkey: validatorVote, isSigner: false, isWritable: false },
+      { pubkey: SYSVAR_RENT_PUBKEY, 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 instruction to update a set of validators in the stake pool.
    */
diff --git a/stake-pool/js/src/utils/program-address.ts b/stake-pool/js/src/utils/program-address.ts
index 5b95ca183..827f40211 100644
--- a/stake-pool/js/src/utils/program-address.ts
+++ b/stake-pool/js/src/utils/program-address.ts
@@ -28,9 +28,14 @@ export async function findStakeProgramAddress(
   programId: PublicKey,
   voteAccountAddress: PublicKey,
   stakePoolAddress: PublicKey,
+  seed?: number,
 ) {
   const [publicKey] = await PublicKey.findProgramAddress(
-    [voteAccountAddress.toBuffer(), stakePoolAddress.toBuffer()],
+    [
+      voteAccountAddress.toBuffer(),
+      stakePoolAddress.toBuffer(),
+      seed ? new BN(seed).toArrayLike(Buffer, 'le', 4) : Buffer.alloc(0),
+    ],
     programId,
   );
   return publicKey;
diff --git a/stake-pool/js/src/utils/stake.ts b/stake-pool/js/src/utils/stake.ts
index 255354318..55debbf60 100644
--- a/stake-pool/js/src/utils/stake.ts
+++ b/stake-pool/js/src/utils/stake.ts
@@ -25,6 +25,7 @@ export async function getValidatorListAccount(connection: Connection, pubkey: Pu
   if (!account) {
     throw new Error('Invalid validator list account');
   }
+
   return {
     pubkey,
     account: {


Test patch:
diff --git a/stake-pool/js/test/instructions.test.ts b/stake-pool/js/test/instructions.test.ts
index 1a39ddffb..6f4b564ca 100644
--- a/stake-pool/js/test/instructions.test.ts
+++ b/stake-pool/js/test/instructions.test.ts
@@ -12,14 +12,16 @@ import {
   Connection,
   Keypair,
   SystemProgram,
+  StakeProgram,
   AccountInfo,
   LAMPORTS_PER_SOL,
 } from '@solana/web3.js';
 import { TOKEN_PROGRAM_ID, TokenAccountNotFoundError } from '@solana/spl-token';
-import { StakePoolLayout } from '../src/layouts';
+import { StakePoolLayout, ValidatorListLayout } from '../src/layouts';
 import {
   STAKE_POOL_INSTRUCTION_LAYOUTS,
   DepositSolParams,
+  AddValidatorToPoolParams,
   StakePoolInstruction,
   depositSol,
   withdrawSol,
@@ -29,6 +31,7 @@ import {
   createPoolTokenMetadata,
   updatePoolTokenMetadata,
   tokenMetadataLayout,
+  addValidatorToPool,
 } from '../src';
 
 import { decodeData } from '../src/utils';
@@ -42,6 +45,7 @@ import {
   CONSTANTS,
   stakeAccountData,
   uninitializedStakeAccount,
+  validatorListMock,
 } from './mocks';
 
 describe('StakePoolProgram', () => {
@@ -61,6 +65,40 @@ describe('StakePoolProgram', () => {
     data,
   };
 
+  it('StakePoolInstruction.addValidatorToPool', () => {
+    const payload: AddValidatorToPoolParams = {
+      stakePool: stakePoolAddress,
+      staker: Keypair.generate().publicKey,
+      reserveStake: Keypair.generate().publicKey,
+      withdrawAuthority: Keypair.generate().publicKey,
+      validatorList: Keypair.generate().publicKey,
+      validatorStake: Keypair.generate().publicKey,
+      validatorVote: PublicKey.default,
+      seed: 0,
+    };
+
+    const instruction = StakePoolInstruction.addValidatorToPool(payload);
+    expect(instruction.keys).toHaveLength(13);
+    expect(instruction.keys[0].pubkey).toEqual(payload.stakePool);
+    expect(instruction.keys[1].pubkey).toEqual(payload.staker);
+    expect(instruction.keys[2].pubkey).toEqual(payload.reserveStake);
+    expect(instruction.keys[3].pubkey).toEqual(payload.withdrawAuthority);
+    expect(instruction.keys[4].pubkey).toEqual(payload.validatorList);
+    expect(instruction.keys[5].pubkey).toEqual(payload.validatorStake);
+    expect(instruction.keys[6].pubkey).toEqual(payload.validatorVote);
+    expect(instruction.keys[11].pubkey).toEqual(SystemProgram.programId);
+    expect(instruction.keys[12].pubkey).toEqual(StakeProgram.programId);
+
+    const decodedData = decodeData(
+      STAKE_POOL_INSTRUCTION_LAYOUTS.AddValidatorToPool,
+      instruction.data,
+    );
+    expect(decodedData.instruction).toEqual(
+      STAKE_POOL_INSTRUCTION_LAYOUTS.AddValidatorToPool.index,
+    );
+    expect(decodedData.seed).toEqual(payload.seed);
+  });
+
   it('StakePoolInstruction.depositSol', () => {
     const payload: DepositSolParams = {
       stakePool: stakePoolAddress,
@@ -99,6 +137,49 @@ describe('StakePoolProgram', () => {
     expect(instruction2.keys[10].pubkey).toEqual(payload.depositAuthority);
   });
 
+  describe('addValidatorToPool', () => {
+    const validatorList = mockValidatorList();
+    const decodedValidatorList = ValidatorListLayout.decode(validatorList.data);
+    const voteAccount = decodedValidatorList.validators[0].voteAccountAddress;
+
+    it('should throw an error when trying to add an existing validator', async () => {
+      connection.getAccountInfo = jest.fn(async (pubKey) => {
+        if (pubKey === stakePoolAddress) {
+          return stakePoolAccount;
+        }
+        return mockValidatorList();
+      });
+      await expect(addValidatorToPool(connection, stakePoolAddress, voteAccount)).rejects.toThrow(
+        Error('Vote account is already in validator list'),
+      );
+    });
+
+    it('should successfully add a validator', async () => {
+      connection.getAccountInfo = jest.fn(async (pubKey) => {
+        if (pubKey === stakePoolAddress) {
+          return stakePoolAccount;
+        }
+        return <AccountInfo<any>>{
+          executable: true,
+          owner: new PublicKey(0),
+          lamports: 0,
+          data,
+        };
+      });
+      const res = await addValidatorToPool(
+        connection,
+        stakePoolAddress,
+        validatorListMock.validators[0].voteAccountAddress,
+      );
+      expect((connection.getAccountInfo as jest.Mock).mock.calls.length).toBe(2);
+      expect(res.instructions).toHaveLength(1);
+      // Make sure that the validator vote account being added is the one we passed
+      expect(res.instructions[0].keys[6].pubkey).toEqual(
+        validatorListMock.validators[0].voteAccountAddress,
+      );
+    });
+  });
+
   describe('depositSol', () => {
     const from = Keypair.generate().publicKey;
     const balance = 10000;