From 4d877567dd4a411a888469b55b162656aa93f94e Mon Sep 17 00:00:00 2001 From: mooori Date: Fri, 4 Feb 2022 21:37:28 +0100 Subject: [PATCH] feat(web3.js): support withdraw from Vote account (#22932) --- web3.js/src/index.ts | 1 + web3.js/src/layout.ts | 15 ++ web3.js/src/vote-program.ts | 290 ++++++++++++++++++++++++++++++ web3.js/test/vote-program.test.ts | 166 +++++++++++++++++ 4 files changed, 472 insertions(+) create mode 100644 web3.js/src/vote-program.ts create mode 100644 web3.js/test/vote-program.test.ts diff --git a/web3.js/src/index.ts b/web3.js/src/index.ts index 5b6a7f18d..e72a998bb 100644 --- a/web3.js/src/index.ts +++ b/web3.js/src/index.ts @@ -17,6 +17,7 @@ export * from './secp256k1-program'; export * from './transaction'; export * from './validator-info'; export * from './vote-account'; +export * from './vote-program'; export * from './sysvar'; export * from './errors'; export * from './util/borsh-schema'; diff --git a/web3.js/src/layout.ts b/web3.js/src/layout.ts index a96c79844..73aa8c16f 100644 --- a/web3.js/src/layout.ts +++ b/web3.js/src/layout.ts @@ -79,6 +79,21 @@ export const lockup = (property: string = 'lockup') => { ); }; +/** + * Layout for a VoteInit object + */ +export const voteInit = (property: string = 'voteInit') => { + return BufferLayout.struct( + [ + publicKey('nodePubkey'), + publicKey('authorizedVoter'), + publicKey('authorizedWithdrawer'), + BufferLayout.u8('commission'), + ], + property, + ); +}; + export function getAlloc(type: any, fields: any): number { let alloc = 0; type.layout.fields.forEach((item: any) => { diff --git a/web3.js/src/vote-program.ts b/web3.js/src/vote-program.ts new file mode 100644 index 000000000..2557504cd --- /dev/null +++ b/web3.js/src/vote-program.ts @@ -0,0 +1,290 @@ +import * as BufferLayout from '@solana/buffer-layout'; + +import {encodeData, decodeData, InstructionType} from './instruction'; +import * as Layout from './layout'; +import {PublicKey} from './publickey'; +import {SystemProgram} from './system-program'; +import {SYSVAR_CLOCK_PUBKEY, SYSVAR_RENT_PUBKEY} from './sysvar'; +import {Transaction, TransactionInstruction} from './transaction'; +import {toBuffer} from './util/to-buffer'; + +/** + * Vote account info + */ +export class VoteInit { + nodePubkey: PublicKey; + authorizedVoter: PublicKey; + authorizedWithdrawer: PublicKey; + commission: number; /** [0, 100] */ + + constructor( + nodePubkey: PublicKey, + authorizedVoter: PublicKey, + authorizedWithdrawer: PublicKey, + commission: number, + ) { + this.nodePubkey = nodePubkey; + this.authorizedVoter = authorizedVoter; + this.authorizedWithdrawer = authorizedWithdrawer; + this.commission = commission; + } +} + +/** + * Create vote account transaction params + */ +export type CreateVoteAccountParams = { + fromPubkey: PublicKey; + votePubkey: PublicKey; + voteInit: VoteInit; + lamports: number; +}; + +/** + * InitializeAccount instruction params + */ +export type InitializeAccountParams = { + votePubkey: PublicKey; + nodePubkey: PublicKey; + voteInit: VoteInit; +}; + +/** + * Withdraw from vote account transaction params + */ +export type WithdrawFromVoteAccountParams = { + votePubkey: PublicKey; + authorizedWithdrawerPubkey: PublicKey; + lamports: number; + toPubkey: PublicKey; +}; + +/** + * Vote Instruction class + */ +export class VoteInstruction { + /** + * @internal + */ + constructor() {} + + /** + * Decode a vote instruction and retrieve the instruction type. + */ + static decodeInstructionType( + instruction: TransactionInstruction, + ): VoteInstructionType { + this.checkProgramId(instruction.programId); + + const instructionTypeLayout = BufferLayout.u32('instruction'); + const typeIndex = instructionTypeLayout.decode(instruction.data); + + let type: VoteInstructionType | undefined; + for (const [ixType, layout] of Object.entries(VOTE_INSTRUCTION_LAYOUTS)) { + if (layout.index == typeIndex) { + type = ixType as VoteInstructionType; + break; + } + } + + if (!type) { + throw new Error('Instruction type incorrect; not a VoteInstruction'); + } + + return type; + } + + /** + * Decode an initialize vote instruction and retrieve the instruction params. + */ + static decodeInitializeAccount( + instruction: TransactionInstruction, + ): InitializeAccountParams { + this.checkProgramId(instruction.programId); + this.checkKeyLength(instruction.keys, 4); + + const {voteInit} = decodeData( + VOTE_INSTRUCTION_LAYOUTS.InitializeAccount, + instruction.data, + ); + + return { + votePubkey: instruction.keys[0].pubkey, + nodePubkey: instruction.keys[3].pubkey, + voteInit: new VoteInit( + new PublicKey(voteInit.nodePubkey), + new PublicKey(voteInit.authorizedVoter), + new PublicKey(voteInit.authorizedWithdrawer), + voteInit.commission, + ), + }; + } + + /** + * Decode a withdraw instruction and retrieve the instruction params. + */ + static decodeWithdraw( + instruction: TransactionInstruction, + ): WithdrawFromVoteAccountParams { + this.checkProgramId(instruction.programId); + this.checkKeyLength(instruction.keys, 3); + + const {lamports} = decodeData( + VOTE_INSTRUCTION_LAYOUTS.Withdraw, + instruction.data, + ); + + return { + votePubkey: instruction.keys[0].pubkey, + authorizedWithdrawerPubkey: instruction.keys[2].pubkey, + lamports, + toPubkey: instruction.keys[1].pubkey, + }; + } + + /** + * @internal + */ + static checkProgramId(programId: PublicKey) { + if (!programId.equals(VoteProgram.programId)) { + throw new Error('invalid instruction; programId is not VoteProgram'); + } + } + + /** + * @internal + */ + static checkKeyLength(keys: Array, expectedLength: number) { + if (keys.length < expectedLength) { + throw new Error( + `invalid instruction; found ${keys.length} keys, expected at least ${expectedLength}`, + ); + } + } +} + +/** + * An enumeration of valid VoteInstructionType's + */ +export type VoteInstructionType = 'InitializeAccount' | 'Withdraw'; + +const VOTE_INSTRUCTION_LAYOUTS: { + [type in VoteInstructionType]: InstructionType; +} = Object.freeze({ + InitializeAccount: { + index: 0, + layout: BufferLayout.struct([ + BufferLayout.u32('instruction'), + Layout.voteInit(), + ]), + }, + Withdraw: { + index: 3, + layout: BufferLayout.struct([ + BufferLayout.u32('instruction'), + BufferLayout.ns64('lamports'), + ]), + }, +}); + +/** + * Factory class for transactions to interact with the Vote program + */ +export class VoteProgram { + /** + * @internal + */ + constructor() {} + + /** + * Public key that identifies the Vote program + */ + static programId: PublicKey = new PublicKey( + 'Vote111111111111111111111111111111111111111', + ); + + /** + * Max space of a Vote account + * + * This is generated from the solana-vote-program VoteState struct as + * `VoteState::size_of()`: + * https://docs.rs/solana-vote-program/1.9.5/solana_vote_program/vote_state/struct.VoteState.html#method.size_of + */ + static space: number = 3731; + + /** + * Generate an Initialize instruction. + */ + static initializeAccount( + params: InitializeAccountParams, + ): TransactionInstruction { + const {votePubkey, nodePubkey, voteInit} = params; + const type = VOTE_INSTRUCTION_LAYOUTS.InitializeAccount; + const data = encodeData(type, { + voteInit: { + nodePubkey: toBuffer(voteInit.nodePubkey.toBuffer()), + authorizedVoter: toBuffer(voteInit.authorizedVoter.toBuffer()), + authorizedWithdrawer: toBuffer( + voteInit.authorizedWithdrawer.toBuffer(), + ), + commission: voteInit.commission, + }, + }); + const instructionData = { + keys: [ + {pubkey: votePubkey, isSigner: false, isWritable: true}, + {pubkey: SYSVAR_RENT_PUBKEY, isSigner: false, isWritable: false}, + {pubkey: SYSVAR_CLOCK_PUBKEY, isSigner: false, isWritable: false}, + {pubkey: nodePubkey, isSigner: true, isWritable: false}, + ], + programId: this.programId, + data, + }; + return new TransactionInstruction(instructionData); + } + + /** + * Generate a transaction that creates a new Vote account. + */ + static createAccount(params: CreateVoteAccountParams): Transaction { + const transaction = new Transaction(); + transaction.add( + SystemProgram.createAccount({ + fromPubkey: params.fromPubkey, + newAccountPubkey: params.votePubkey, + lamports: params.lamports, + space: this.space, + programId: this.programId, + }), + ); + + return transaction.add( + this.initializeAccount({ + votePubkey: params.votePubkey, + nodePubkey: params.voteInit.nodePubkey, + voteInit: params.voteInit, + }), + ); + } + + /** + * Generate a transaction to withdraw from a Vote account. + */ + static withdraw(params: WithdrawFromVoteAccountParams): Transaction { + const {votePubkey, authorizedWithdrawerPubkey, lamports, toPubkey} = params; + const type = VOTE_INSTRUCTION_LAYOUTS.Withdraw; + const data = encodeData(type, {lamports}); + + const keys = [ + {pubkey: votePubkey, isSigner: false, isWritable: true}, + {pubkey: toPubkey, isSigner: false, isWritable: true}, + {pubkey: authorizedWithdrawerPubkey, isSigner: true, isWritable: false}, + ]; + + return new Transaction().add({ + keys, + programId: this.programId, + data, + }); + } +} diff --git a/web3.js/test/vote-program.test.ts b/web3.js/test/vote-program.test.ts new file mode 100644 index 000000000..e8bb371c4 --- /dev/null +++ b/web3.js/test/vote-program.test.ts @@ -0,0 +1,166 @@ +import {expect, use} from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +import { + Keypair, + LAMPORTS_PER_SOL, + VoteInit, + VoteInstruction, + VoteProgram, + sendAndConfirmTransaction, + SystemInstruction, + Connection, +} from '../src'; +import {helpers} from './mocks/rpc-http'; +import {url} from './url'; + +use(chaiAsPromised); + +describe('VoteProgram', () => { + it('createAccount', () => { + const fromPubkey = Keypair.generate().publicKey; + const newAccountPubkey = Keypair.generate().publicKey; + const authorizedPubkey = Keypair.generate().publicKey; + const nodePubkey = Keypair.generate().publicKey; + const commission = 5; + const voteInit = new VoteInit( + nodePubkey, + authorizedPubkey, + authorizedPubkey, + commission, + ); + const lamports = 123; + const transaction = VoteProgram.createAccount({ + fromPubkey, + votePubkey: newAccountPubkey, + voteInit, + lamports, + }); + expect(transaction.instructions).to.have.length(2); + const [systemInstruction, voteInstruction] = transaction.instructions; + const systemParams = { + fromPubkey, + newAccountPubkey, + lamports, + space: VoteProgram.space, + programId: VoteProgram.programId, + }; + expect(systemParams).to.eql( + SystemInstruction.decodeCreateAccount(systemInstruction), + ); + + const initParams = {votePubkey: newAccountPubkey, nodePubkey, voteInit}; + expect(initParams).to.eql( + VoteInstruction.decodeInitializeAccount(voteInstruction), + ); + }); + + it('initialize', () => { + const newAccountPubkey = Keypair.generate().publicKey; + const authorizedPubkey = Keypair.generate().publicKey; + const nodePubkey = Keypair.generate().publicKey; + const voteInit = new VoteInit( + nodePubkey, + authorizedPubkey, + authorizedPubkey, + 5, + ); + const initParams = { + votePubkey: newAccountPubkey, + nodePubkey, + voteInit, + }; + const initInstruction = VoteProgram.initializeAccount(initParams); + expect(initParams).to.eql( + VoteInstruction.decodeInitializeAccount(initInstruction), + ); + }); + + it('withdraw', () => { + const votePubkey = Keypair.generate().publicKey; + const authorizedWithdrawerPubkey = Keypair.generate().publicKey; + const toPubkey = Keypair.generate().publicKey; + const params = { + votePubkey, + authorizedWithdrawerPubkey, + lamports: 123, + toPubkey, + }; + const transaction = VoteProgram.withdraw(params); + expect(transaction.instructions).to.have.length(1); + const [withdrawInstruction] = transaction.instructions; + expect(params).to.eql(VoteInstruction.decodeWithdraw(withdrawInstruction)); + }); + + if (process.env.TEST_LIVE) { + it('live vote actions', async () => { + const connection = new Connection(url, 'confirmed'); + + const newVoteAccount = Keypair.generate(); + const nodeAccount = Keypair.generate(); + + const payer = Keypair.generate(); + await helpers.airdrop({ + connection, + address: payer.publicKey, + amount: 12 * LAMPORTS_PER_SOL, + }); + expect(await connection.getBalance(payer.publicKey)).to.eq( + 12 * LAMPORTS_PER_SOL, + ); + + const authorized = Keypair.generate(); + await helpers.airdrop({ + connection, + address: authorized.publicKey, + amount: 12 * LAMPORTS_PER_SOL, + }); + expect(await connection.getBalance(authorized.publicKey)).to.eq( + 12 * LAMPORTS_PER_SOL, + ); + + const minimumAmount = await connection.getMinimumBalanceForRentExemption( + VoteProgram.space, + ); + + // Create initialized Vote account + let createAndInitialize = VoteProgram.createAccount({ + fromPubkey: payer.publicKey, + votePubkey: newVoteAccount.publicKey, + voteInit: new VoteInit( + nodeAccount.publicKey, + authorized.publicKey, + authorized.publicKey, + 5, + ), + lamports: minimumAmount + 2 * LAMPORTS_PER_SOL, + }); + + await sendAndConfirmTransaction( + connection, + createAndInitialize, + [payer, newVoteAccount, nodeAccount], + {preflightCommitment: 'confirmed'}, + ); + expect(await connection.getBalance(newVoteAccount.publicKey)).to.eq( + minimumAmount + 2 * LAMPORTS_PER_SOL, + ); + + // Withdraw from Vote account + const recipient = Keypair.generate(); + let withdraw = VoteProgram.withdraw({ + votePubkey: newVoteAccount.publicKey, + authorizedWithdrawerPubkey: authorized.publicKey, + lamports: LAMPORTS_PER_SOL, + toPubkey: recipient.publicKey, + }); + + await sendAndConfirmTransaction(connection, withdraw, [authorized], { + preflightCommitment: 'confirmed', + }); + expect(await connection.getBalance(recipient.publicKey)).to.eq( + LAMPORTS_PER_SOL, + ); + }).timeout(10 * 1000); + } +});