diff --git a/.eslintrc.json b/.eslintrc.json index 657850da..3c4f70a9 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -4,7 +4,8 @@ "semi": ["error", "never"], "node/no-extraneous-import": ["error", { "allowModules": ["@marinade.finance/jest-utils"] - }] + }], + "@typescript-eslint/no-extra-semi" : "off" }, "settings": { "node": { diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..a5d83100 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. + +## [1.0.0](https://github.com/marinade-finance/validator-bonds/compare/v1.0.0) (2023-12-31) + + +### Features + +* SDK and CLI with init, configure and show `Config` and `Bond` accounts \ No newline at end of file diff --git a/README.md b/README.md index 38eaa0de..0eb8fc88 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,43 @@ pnpm install # building Anchor program + cli and sdk TS packages pnpm build -# testing the SDK+CLI against the local validator running the program +# testing the SDK+CLI against the bankrun and local validator pnpm test +# bankrun part of the tests +pnpm test:bankrun +# local validator part of the tests +pnpm test:validator +# cargo tests in rust code +pnpm test:cargo ``` + +### Contract deployment + +```sh +anchor build --verifiable + +# deploy +solana program deploy -v -ud \ + --program-id vBoNdEvzMrSai7is21XgVYik65mqtaKXuSdMBJ1xkW4 \ + -k [fee-payer-keypair] + --upgrade-authority [path-to-keypair] \ + ./target/verifiable/validator_bonds.so + +# upgrade with SPL Gov authority (generic MNDE realm upgrade authority, governance 7iUtT...wtBZY) +solana -ud program write-buffer target/verifiable/validator_bonds.so +solana program set-buffer-authority --new-buffer-authority 6YAju4nd4t7kyuHV6NvVpMepMk11DgWyYjKVJUak2EEm + +# publish IDL (account Du3XrzTNqhLt9gpui9LUogrLqCDrVC2HrtiNXHSJM58y) +anchor --provider.cluster devnet idl \ + # init vBoNdEvzMrSai7is21XgVYik65mqtaKXuSdMBJ1xkW4 \ + upgrade vBoNdEvzMrSai7is21XgVYik65mqtaKXuSdMBJ1xkW4 \ + -f ./target/idl/validator_bonds.json + +# check verifiable deployment ( can be verified as well) +anchor --provider.cluster devnet \ + verify -p validator_bonds \ + vBoNdEvzMrSai7is21XgVYik65mqtaKXuSdMBJ1xkW4 +``` + +// TODO: add table of authorities - what state means what authority +// TODO: add flow diagram how calls will be done \ No newline at end of file diff --git a/Test.toml b/Test.toml index b74d94b2..b84dadc7 100644 --- a/Test.toml +++ b/Test.toml @@ -2,3 +2,6 @@ # recursive call: pnpm test -> anchor test -> pnpm _test # using solana-bankrun for testing (--runInBand is needed, see https://github.com/kevinheavey/solana-bankrun/issues/2) test = "pnpm _test" + +[test.validator] +slots_per_epoch = "32" \ No newline at end of file diff --git a/jest.config.bankrun.js b/jest.config.bankrun.js index 1d6f7caa..cc90ceb4 100644 --- a/jest.config.bankrun.js +++ b/jest.config.bankrun.js @@ -4,11 +4,10 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', modulePathIgnorePatterns: ['/build/'], + testRegex: ['__tests__/bankrun/.*.spec.ts'], testPathIgnorePatterns: ['.*utils.*'], - testRegex: ['__tests__/bankrun/.*'], - // globalSetup: // TODO: uncomment or remove - // '/packages/validator-bonds-sdk/__tests__/setup/globalSetup.ts' setupFilesAfterEnv: [ + // https://github.com/marinade-finance/marinade-ts-cli/blob/main/packages/lib/jest-utils/src/equalityTesters.ts '/node_modules/@marinade.finance/jest-utils/src/equalityTesters', ], } diff --git a/jest.config.test-validator.js b/jest.config.test-validator.js index e52b1609..9c149401 100644 --- a/jest.config.test-validator.js +++ b/jest.config.test-validator.js @@ -4,11 +4,12 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', modulePathIgnorePatterns: ['/build/'], - testRegex: ['__tests__/test-validator/.*'], + testRegex: ['__tests__/test-validator/.*.spec.ts'], testPathIgnorePatterns: ['.*utils.*'], testTimeout: 120000, detectOpenHandles: true, setupFilesAfterEnv: [ + /// https://github.com/marinade-finance/marinade-ts-cli/blob/main/packages/lib/jest-utils/src/equalityTesters.ts '/node_modules/@marinade.finance/jest-utils/src/equalityTesters', ], } diff --git a/package.json b/package.json index 0f2fd4b8..739563e8 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,10 @@ "compile": "pnpm anchor:build && tsc --project tsconfig.build.json", "build": "pnpm compile", "_test": "pnpm jest --config jest.config.test-validator.js -- \"$FILE\"", - "test:test-validator": "BROWSER= anchor test", + "test:validator": "anchor test", "test:bankrun": "pnpm jest --config jest.config.bankrun.js --runInBand true -- \"$FILE\"", "test:cargo": "cargo test --features no-entrypoint", - "test": "pnpm test:cargo && pnpm test:bankrun && pnpm test:test-validator", + "test": "pnpm test:cargo && pnpm test:bankrun && pnpm test:validator", "cli": "ts-node ./packages/validator-bonds-cli/src/", "lint:cargo": "cargo fmt -- --check && cargo clippy", "lint:cargo-fix": "cargo fmt --all && cargo clippy --fix --allow-staged --allow-dirty", @@ -17,7 +17,9 @@ "lint:ts-clean": "gts clean", "lint:fix": "pnpm lint:ts-fix && pnpm lint:cargo-fix", "lint": "pnpm lint:cargo && pnpm lint:ts", - "publish-sdk": "pnpm build && pnpm publish build/packages/validator-bonds-sdk" + "publish-sdk": "pnpm build && pnpm publish build/packages/validator-bonds-sdk", + "publish-cli": "pnpm build && pnpm publish build/packages/validator-bonds-cli", + "publish": "pnpm publish-sdk && pnpm publish-cli" }, "devDependencies": { "@jest/globals": "^29.7.0", @@ -29,7 +31,7 @@ "ts-jest": "^29.1.1", "ts-node": "^10.9.1", "typescript": "4.9.5", - "@marinade.finance/jest-utils": "^2.0.18" + "@marinade.finance/jest-utils": "^2.0.20" }, "pnpm": { "peerDependencyRules": { diff --git a/packages/validator-bonds-cli/README.md b/packages/validator-bonds-cli/README.md new file mode 100644 index 00000000..4f4807b5 --- /dev/null +++ b/packages/validator-bonds-cli/README.md @@ -0,0 +1,72 @@ +# Validator Bonds CLI + +CLI for Validator Bonds contract. + +## Working with CLI + +To install the CLI as global npm package + +```bash +npm i -g @marinade.finance/validator-bonds-cli +validator-bonds --help +``` + +### Creating a bond + +Any validator may create a bond account to protect the processing. +The bond account is bound to a validator vote account. + +```sh +# bond account at mainnet +validator-bonds -um init-bond -k \ + --vote-account --vote-account-withdrawer \ + --bond-authority --rent-payer + +# to configure bond account properties +validator-bonds -um configure-bond --help +``` + +### Show the bond account + +```sh +validator-bonds -um show-bond -f yaml +``` + + + +## `validator-bonds --help` + +```sh +pnpm cli --help + +> @ cli /home/chalda/marinade/validator-bonds +> ts-node ./packages/validator-bonds-cli/src/ "--help" + +Usage: src [options] [command] + +Options: + -V, --version output the version number + -u, --cluster solana cluster URL, accepts shortcuts (d/devnet, m/mainnet) (default: "http://127.0.0.1:8899") + -c alias for "-u, --cluster" + --commitment Commitment (default: "confirmed") + -k, --keypair Wallet keypair (path or ledger url in format usb://ledger/[][?key=]) (default: ~/.config/solana/id.json) + --program-id Program id of validator bonds contract (default: vBoNdEvzMrSai7is21XgVYik65mqtaKXuSdMBJ1xkW4) + -s, --simulate Simulate (default: false) + -p, --print-only Print only mode, no execution, instructions are printed in base64 to output. This can be used for placing the admin commands to SPL + Governance UI by hand. (default: false) + --skip-preflight transaction execution flag "skip-preflight", see https://solanacookbook.com/guides/retrying-transactions.html#the-cost-of-skipping-preflight + (default: false) + -d, --debug printing more detailed information of the CLI execution (default: false) + -v, --verbose alias for --debug (default: false) + -h, --help display help for command + +Commands: + init-config [options] Create a new config account. + configure-config [options] [config-account-address] Configure existing config account. + init-bond [options] Create a new bond account. + configure-bond [options] [bond-account-address] Configure existing bond account. + show-config [options] [address] Showing data of config account(s) + show-event [options] Showing data of anchor event + show-bond [options] [address] Showing data of bond account(s) + help [command] display help for command +``` \ No newline at end of file diff --git a/packages/validator-bonds-cli/__tests__/test-validator/configureBond.spec.ts b/packages/validator-bonds-cli/__tests__/test-validator/configureBond.spec.ts new file mode 100644 index 00000000..98732d91 --- /dev/null +++ b/packages/validator-bonds-cli/__tests__/test-validator/configureBond.spec.ts @@ -0,0 +1,177 @@ +import { createTempFileKeypair } from '@marinade.finance/web3js-common' +import { shellMatchers } from '@marinade.finance/jest-utils' +import { Keypair, PublicKey } from '@solana/web3.js' +import { + ValidatorBondsProgram, + bondAddress, + getBond, +} from '@marinade.finance/validator-bonds-sdk' +import { + executeInitBondInstruction, + executeInitConfigInstruction, +} from '@marinade.finance/validator-bonds-sdk/__tests__/utils/testTransactions' +import { + AnchorExtendedProvider, + initTest, +} from '@marinade.finance/validator-bonds-sdk/__tests__/test-validator/testValidator' +import { createVoteAccount } from '@marinade.finance/validator-bonds-sdk/__tests__/utils/staking' + +describe('Configure bond account using CLI', () => { + let provider: AnchorExtendedProvider + let program: ValidatorBondsProgram + let voteWithdrawerPath: string + let voteWithdrawerKeypair: Keypair + let voteWithdrawerCleanup: () => Promise + let bondAuthorityPath: string + let bondAuthorityKeypair: Keypair + let bondAuthorityCleanup: () => Promise + let configAccount: PublicKey + let bondAccount: PublicKey + let voteAccount: PublicKey + + beforeAll(async () => { + shellMatchers() + ;({ provider, program } = await initTest()) + }) + + beforeEach(async () => { + ;({ + path: voteWithdrawerPath, + keypair: voteWithdrawerKeypair, + cleanup: voteWithdrawerCleanup, + } = await createTempFileKeypair()) + ;({ + path: bondAuthorityPath, + keypair: bondAuthorityKeypair, + cleanup: bondAuthorityCleanup, + } = await createTempFileKeypair()) + ;({ configAccount } = await executeInitConfigInstruction({ + program, + provider, + epochsToClaimSettlement: 1, + withdrawLockupEpochs: 2, + })) + expect( + provider.connection.getAccountInfo(configAccount) + ).resolves.not.toBeNull() + ;({ voteAccount } = await createVoteAccount( + provider, + undefined, + undefined, + voteWithdrawerKeypair + )) + ;({ bondAccount } = await executeInitBondInstruction( + program, + provider, + configAccount, + bondAuthorityKeypair, + voteAccount, + voteWithdrawerKeypair, + 33 + )) + }) + + afterEach(async () => { + await bondAuthorityCleanup() + await voteWithdrawerCleanup() + }) + + it('configure bond account', async () => { + await ( + expect([ + 'pnpm', + [ + 'cli', + '-u', + provider.connection.rpcEndpoint, + '--program-id', + program.programId.toBase58(), + 'configure-bond', + bondAccount.toBase58(), + '--authority', + bondAuthorityPath, + '--revenue-share', + '42', + ], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ]) as any + ).toHaveMatchingSpawnOutput({ + code: 0, + // stderr: '', + stdout: /Bond account.*successfully configured/, + }) + + const [, bump] = bondAddress(configAccount, voteAccount, program.programId) + const bondsData1 = await getBond(program, bondAccount) + expect(bondsData1.config).toEqual(configAccount) + expect(bondsData1.validatorVoteAccount).toEqual(voteAccount) + expect(bondsData1.authority).toEqual(bondAuthorityKeypair.publicKey) + expect(bondsData1.revenueShare.hundredthBps).toEqual(42 * 10 ** 4) + expect(bondsData1.bump).toEqual(bump) + + const newBondAuthority = PublicKey.unique() + await ( + expect([ + 'pnpm', + [ + 'cli', + '-u', + provider.connection.rpcEndpoint, + '--program-id', + program.programId.toBase58(), + 'configure-bond', + '--config', + configAccount.toBase58(), + '--vote-account', + voteAccount.toBase58(), + '--authority', + voteWithdrawerPath, + '--bond-authority', + newBondAuthority.toBase58(), + '--revenue-share', + 43, + ], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ]) as any + ).toHaveMatchingSpawnOutput({ + code: 0, + // stderr: '', + stdout: /Bond account.*successfully configured/, + }) + + const bondsData2 = await getBond(program, bondAccount) + expect(bondsData2.authority).toEqual(newBondAuthority) + expect(bondsData2.revenueShare.hundredthBps).toEqual(43 * 10 ** 4) + }) + + it('configure bond in print-only mode', async () => { + await ( + expect([ + 'pnpm', + [ + 'cli', + '-u', + provider.connection.rpcEndpoint, + '--program-id', + program.programId.toBase58(), + 'configure-bond', + bondAccount.toBase58(), + '--authority', + bondAuthorityKeypair.publicKey.toBase58(), + '--bond-authority', + PublicKey.unique().toBase58(), + '--print-only', + ], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ]) as any + ).toHaveMatchingSpawnOutput({ + code: 0, + // stderr: '', + stdout: /successfully configured/, + }) + + expect((await getBond(program, bondAccount)).authority).toEqual( + bondAuthorityKeypair.publicKey + ) + }) +}) diff --git a/packages/validator-bonds-cli/__tests__/test-validator/configureConfig.spec.ts b/packages/validator-bonds-cli/__tests__/test-validator/configureConfig.spec.ts new file mode 100644 index 00000000..4da0fb9e --- /dev/null +++ b/packages/validator-bonds-cli/__tests__/test-validator/configureConfig.spec.ts @@ -0,0 +1,143 @@ +import { createTempFileKeypair } from '@marinade.finance/web3js-common' +import { shellMatchers } from '@marinade.finance/jest-utils' +import { Keypair, PublicKey } from '@solana/web3.js' +import { + ValidatorBondsProgram, + getConfig, +} from '@marinade.finance/validator-bonds-sdk' +import { executeInitConfigInstruction } from '@marinade.finance/validator-bonds-sdk/__tests__/utils/testTransactions' +import { + AnchorExtendedProvider, + initTest, +} from '@marinade.finance/validator-bonds-sdk/__tests__/test-validator/testValidator' + +describe('Configure config account using CLI', () => { + let provider: AnchorExtendedProvider + let program: ValidatorBondsProgram + let adminPath: string + let adminKeypair: Keypair + let adminCleanup: () => Promise + let configAccount: PublicKey + let operatorAuthority: Keypair + + beforeAll(async () => { + shellMatchers() + ;({ provider, program } = await initTest()) + }) + + beforeEach(async () => { + ;({ + path: adminPath, + keypair: adminKeypair, + cleanup: adminCleanup, + } = await createTempFileKeypair()) + ;({ configAccount, operatorAuthority } = await executeInitConfigInstruction( + { + program, + provider, + adminAuthority: adminKeypair, + epochsToClaimSettlement: 1, + withdrawLockupEpochs: 2, + } + )) + expect( + provider.connection.getAccountInfo(configAccount) + ).resolves.not.toBeNull() + }) + + afterEach(async () => { + await adminCleanup() + }) + + it('configure config account', async () => { + const newAdmin = Keypair.generate() + + await ( + expect([ + 'pnpm', + [ + 'cli', + '-u', + provider.connection.rpcEndpoint, + '--program-id', + program.programId.toBase58(), + 'configure-config', + configAccount.toBase58(), + ], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ]) as any + ).toHaveMatchingSpawnOutput({ + code: 1, + // stderr: '', + stdout: /No new config values provided/, + }) + + await ( + expect([ + 'pnpm', + [ + 'cli', + '-u', + provider.connection.rpcEndpoint, + '--program-id', + program.programId.toBase58(), + 'configure-config', + configAccount.toBase58(), + '--admin-authority', + adminPath, + '--operator', + PublicKey.default.toBase58(), + '--admin', + newAdmin.publicKey.toBase58(), + '--epochs-to-claim-settlement', + 111, + '--withdraw-lockup-epochs', + 112, + '--minimum-stake-lamports', + 134, + '-v', + ], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ]) as any + ).toHaveMatchingSpawnOutput({ + code: 0, + // stderr: '', + stdout: /successfully configured/, + }) + + const configData = await getConfig(program, configAccount) + expect(configData.adminAuthority).toEqual(newAdmin.publicKey) + expect(configData.operatorAuthority).toEqual(PublicKey.default) + expect(configData.epochsToClaimSettlement).toEqual(111) + expect(configData.withdrawLockupEpochs).toEqual(112) + expect(configData.minimumStakeLamports).toEqual(134) + }) + + it('configure config in print-only mode', async () => { + await ( + expect([ + 'pnpm', + [ + 'cli', + '-u', + provider.connection.rpcEndpoint, + 'configure-config', + configAccount.toBase58(), + '--admin-authority', + adminKeypair.publicKey.toBase58(), + '--operator', + PublicKey.default.toBase58(), + '--print-only', + ], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ]) as any + ).toHaveMatchingSpawnOutput({ + code: 0, + // stderr: '', + stdout: /successfully configured/, + }) + expect((await getConfig(program, configAccount)).operatorAuthority).toEqual( + operatorAuthority.publicKey + ) + }) +}) diff --git a/packages/validator-bonds-cli/__tests__/test-validator/initBond.spec.ts b/packages/validator-bonds-cli/__tests__/test-validator/initBond.spec.ts new file mode 100644 index 00000000..6223b661 --- /dev/null +++ b/packages/validator-bonds-cli/__tests__/test-validator/initBond.spec.ts @@ -0,0 +1,168 @@ +import { createTempFileKeypair } from '@marinade.finance/web3js-common' +import { shellMatchers } from '@marinade.finance/jest-utils' +import { + Keypair, + LAMPORTS_PER_SOL, + PublicKey, + SystemProgram, + Transaction, +} from '@solana/web3.js' +import { + ValidatorBondsProgram, + bondAddress, + getBond, +} from '@marinade.finance/validator-bonds-sdk' +import { executeInitConfigInstruction } from '@marinade.finance/validator-bonds-sdk/__tests__/utils/testTransactions' +import { + AnchorExtendedProvider, + initTest, +} from '@marinade.finance/validator-bonds-sdk/__tests__/test-validator/testValidator' +import { createVoteAccount } from '@marinade.finance/validator-bonds-sdk/__tests__/utils/staking' + +describe('Init bond account using CLI', () => { + let provider: AnchorExtendedProvider + let program: ValidatorBondsProgram + let rentPayerPath: string + let rentPayerKeypair: Keypair + let rentPayerCleanup: () => Promise + const rentPayerFunds = 10 * LAMPORTS_PER_SOL + let voteWithdrawerPath: string + let voteWithdrawerKeypair: Keypair + let voteWithdrawerCleanup: () => Promise + let configAccount: PublicKey + let voteAccount: PublicKey + + beforeAll(async () => { + shellMatchers() + ;({ provider, program } = await initTest()) + }) + + beforeEach(async () => { + ;({ + path: rentPayerPath, + keypair: rentPayerKeypair, + cleanup: rentPayerCleanup, + } = await createTempFileKeypair()) + ;({ + path: voteWithdrawerPath, + keypair: voteWithdrawerKeypair, + cleanup: voteWithdrawerCleanup, + } = await createTempFileKeypair()) + ;({ configAccount } = await executeInitConfigInstruction({ + program, + provider, + epochsToClaimSettlement: 1, + withdrawLockupEpochs: 2, + })) + expect( + provider.connection.getAccountInfo(configAccount) + ).resolves.not.toBeNull() + ;({ voteAccount } = await createVoteAccount( + provider, + undefined, + undefined, + voteWithdrawerKeypair + )) + + const tx = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: provider.wallet.publicKey, + toPubkey: rentPayerKeypair.publicKey, + lamports: rentPayerFunds, + }) + ) + await provider.sendAndConfirm!(tx) + await expect( + provider.connection.getBalance(rentPayerKeypair.publicKey) + ).resolves.toStrictEqual(rentPayerFunds) + }) + + afterEach(async () => { + await rentPayerCleanup() + await voteWithdrawerCleanup() + }) + + it('init bond account', async () => { + const bondAuthority = Keypair.generate() + + await ( + expect([ + 'pnpm', + [ + 'cli', + '-u', + provider.connection.rpcEndpoint, + '--program-id', + program.programId.toBase58(), + 'init-bond', + '--config', + configAccount.toBase58(), + '--vote-account', + voteAccount.toBase58(), + '--vote-account-withdrawer', + voteWithdrawerPath, + '--bond-authority', + bondAuthority.publicKey.toBase58(), + '--revenue-share', + '10', + '--rent-payer', + rentPayerPath, + ], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ]) as any + ).toHaveMatchingSpawnOutput({ + code: 0, + // stderr: '', + stdout: /Bond account.*successfully created/, + }) + + const [bondAccount, bump] = bondAddress( + configAccount, + voteAccount, + program.programId + ) + const bondsData = await getBond(program, bondAccount) + expect(bondsData.config).toEqual(configAccount) + expect(bondsData.validatorVoteAccount).toEqual(voteAccount) + expect(bondsData.authority).toEqual(bondAuthority.publicKey) + expect(bondsData.revenueShare.hundredthBps).toEqual(10 * 10 ** 4) + expect(bondsData.bump).toEqual(bump) + await expect( + provider.connection.getBalance(rentPayerKeypair.publicKey) + ).resolves.toBeLessThan(rentPayerFunds) + }) + + it('init bond in print-only mode', async () => { + await ( + expect([ + 'pnpm', + [ + 'cli', + '-u', + provider.connection.rpcEndpoint, + '--program-id', + program.programId.toBase58(), + 'init-bond', + '--config', + configAccount.toBase58(), + '--vote-account', + voteAccount.toBase58(), + '--print-only', + ], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ]) as any + ).toHaveMatchingSpawnOutput({ + code: 0, + // stderr: '', + stdout: /successfully created/, + }) + const [bondAccount] = bondAddress( + configAccount, + voteAccount, + program.programId + ) + await expect( + provider.connection.getAccountInfo(bondAccount) + ).resolves.toBeNull() + }) +}) diff --git a/packages/validator-bonds-cli/__tests__/test-validator/initConfig.spec.ts b/packages/validator-bonds-cli/__tests__/test-validator/initConfig.spec.ts index 0d2c16de..438dace3 100644 --- a/packages/validator-bonds-cli/__tests__/test-validator/initConfig.spec.ts +++ b/packages/validator-bonds-cli/__tests__/test-validator/initConfig.spec.ts @@ -11,16 +11,11 @@ import { ValidatorBondsProgram, getConfig, } from '@marinade.finance/validator-bonds-sdk' -import { initTest } from './utils' - -beforeAll(() => { - shellMatchers() -}) +import { initTest } from '@marinade.finance/validator-bonds-sdk/__tests__/test-validator/testValidator' describe('Init config account using CLI', () => { let provider: AnchorProvider let program: ValidatorBondsProgram - let configPath: string let configKeypair: Keypair let configCleanup: () => Promise @@ -31,7 +26,6 @@ describe('Init config account using CLI', () => { }) beforeEach(async () => { - // eslint-disable-next-line @typescript-eslint/no-extra-semi ;({ path: configPath, keypair: configKeypair, @@ -110,8 +104,8 @@ describe('Init config account using CLI', () => { ).resolves.toBeLessThan(rentPayerFunds) }) + // this is a "mock test" that just checks that print only command works it('creates config in print-only mode', async () => { - // this is a "mock test" that just checks that print only command works await ( expect([ 'pnpm', diff --git a/packages/validator-bonds-cli/__tests__/test-validator/show.spec.ts b/packages/validator-bonds-cli/__tests__/test-validator/show.spec.ts index 2e1d231e..734a4a55 100644 --- a/packages/validator-bonds-cli/__tests__/test-validator/show.spec.ts +++ b/packages/validator-bonds-cli/__tests__/test-validator/show.spec.ts @@ -1,22 +1,31 @@ -import { AnchorProvider } from '@coral-xyz/anchor' import { shellMatchers } from '@marinade.finance/jest-utils' import YAML from 'yaml' import { + bondAddress, initConfigInstruction, - findBondsWithdrawerAuthority, ValidatorBondsProgram, + withdrawerAuthority, } from '@marinade.finance/validator-bonds-sdk' import { executeTxSimple } from '@marinade.finance/web3js-common' import { transaction } from '@marinade.finance/anchor-common' -import { Keypair } from '@solana/web3.js' -import { initTest } from './utils' +import { Keypair, LAMPORTS_PER_SOL } from '@solana/web3.js' +import { + AnchorExtendedProvider, + initTest, +} from '@marinade.finance/validator-bonds-sdk/__tests__/test-validator/testValidator' +import { signerWithPubkey } from '@marinade.finance/validator-bonds-sdk/__tests__/utils/helpers' +import { + executeInitBondInstruction, + executeInitConfigInstruction, +} from '@marinade.finance/validator-bonds-sdk/__tests__/utils/testTransactions' +import { createVoteAccount } from '@marinade.finance/validator-bonds-sdk/__tests__/utils/staking' beforeAll(() => { shellMatchers() }) describe('Show command using CLI', () => { - let provider: AnchorProvider + let provider: AnchorExtendedProvider let program: ValidatorBondsProgram beforeAll(async () => { @@ -26,21 +35,25 @@ describe('Show command using CLI', () => { it('show config', async () => { const tx = await transaction(provider) - const adminAuthority = Keypair.generate().publicKey - const operatorAuthority = Keypair.generate().publicKey - const { instruction: initConfigIx, keypair } = await initConfigInstruction({ - program, - adminAuthority, - operatorAuthority, - epochsToClaimSettlement: 101, - withdrawLockupEpochs: 102, - }) + const admin = Keypair.generate().publicKey + const operator = Keypair.generate().publicKey + const { instruction: initConfigIx, configAccount } = + await initConfigInstruction({ + program, + admin, + operator, + epochsToClaimSettlement: 101, + withdrawLockupEpochs: 102, + }) tx.add(initConfigIx) - await executeTxSimple(provider.connection, tx, [provider.wallet, keypair!]) + const [configKeypair, configPubkey] = signerWithPubkey(configAccount) + await executeTxSimple(provider.connection, tx, [ + provider.wallet, + configKeypair, + ]) - const configAccountAddress = keypair!.publicKey - const [, bondsWithdrawerAuthorityBump] = findBondsWithdrawerAuthority( - configAccountAddress, + const [, bondsWithdrawerAuthorityBump] = withdrawerAuthority( + configPubkey, program.programId ) await ( @@ -54,7 +67,7 @@ describe('Show command using CLI', () => { '--program-id', program.programId.toBase58(), 'show-config', - configAccountAddress.toBase58(), + configPubkey.toBase58(), '-f', 'yaml', ], @@ -66,13 +79,13 @@ describe('Show command using CLI', () => { // stderr: '', stdout: YAML.stringify({ programId: program.programId, - publicKey: configAccountAddress.toBase58(), + publicKey: configPubkey.toBase58(), account: { - adminAuthority: adminAuthority.toBase58(), - operatorAuthority: operatorAuthority.toBase58(), + adminAuthority: admin.toBase58(), + operatorAuthority: operator.toBase58(), epochsToClaimSettlement: 101, withdrawLockupEpochs: 102, - minimumStakeLamports: 1000000000, + minimumStakeLamports: LAMPORTS_PER_SOL, bondsWithdrawerAuthorityBump, reserved: [512], }, @@ -91,7 +104,7 @@ describe('Show command using CLI', () => { program.programId.toBase58(), 'show-config', '--admin', - adminAuthority.toBase58(), + admin.toBase58(), '-f', 'yaml', ], @@ -104,13 +117,13 @@ describe('Show command using CLI', () => { stdout: YAML.stringify([ { programId: program.programId, - publicKey: configAccountAddress.toBase58(), + publicKey: configPubkey.toBase58(), account: { - adminAuthority: adminAuthority.toBase58(), - operatorAuthority: operatorAuthority.toBase58(), + adminAuthority: admin.toBase58(), + operatorAuthority: operator.toBase58(), epochsToClaimSettlement: 101, withdrawLockupEpochs: 102, - minimumStakeLamports: 1000000000, + minimumStakeLamports: LAMPORTS_PER_SOL, bondsWithdrawerAuthorityBump, reserved: [512], }, @@ -156,7 +169,7 @@ describe('Show command using CLI', () => { program.programId.toBase58(), 'show-config', '--operator', - operatorAuthority.toBase58(), + operator.toBase58(), '-f', 'yaml', ], @@ -169,13 +182,13 @@ describe('Show command using CLI', () => { stdout: YAML.stringify([ { programId: program.programId, - publicKey: configAccountAddress.toBase58(), + publicKey: configPubkey.toBase58(), account: { - adminAuthority: adminAuthority.toBase58(), - operatorAuthority: operatorAuthority.toBase58(), + adminAuthority: admin.toBase58(), + operatorAuthority: operator.toBase58(), epochsToClaimSettlement: 101, withdrawLockupEpochs: 102, - minimumStakeLamports: 1000000000, + minimumStakeLamports: LAMPORTS_PER_SOL, bondsWithdrawerAuthorityBump, reserved: [512], }, @@ -183,4 +196,197 @@ describe('Show command using CLI', () => { ]), }) }) + + it('show bond', async () => { + const { configAccount } = await executeInitConfigInstruction({ + program, + provider, + epochsToClaimSettlement: 1, + withdrawLockupEpochs: 2, + }) + expect( + provider.connection.getAccountInfo(configAccount) + ).resolves.not.toBeNull() + const { voteAccount, authorizedWithdrawer } = await createVoteAccount( + provider + ) + const bondAuthority = Keypair.generate() + const { bondAccount } = await executeInitBondInstruction( + program, + provider, + configAccount, + bondAuthority, + voteAccount, + authorizedWithdrawer, + 222 + ) + const [, bump] = bondAddress(configAccount, voteAccount, program.programId) + + const expectedData = { + programId: program.programId, + publicKey: bondAccount.toBase58(), + account: { + config: configAccount.toBase58(), + validatorVoteAccount: voteAccount.toBase58(), + authority: bondAuthority.publicKey.toBase58(), + revenueShare: { hundredthBps: 222 }, + bump, + // TODO: this is strange format + reserved: { reserved: [150] }, + }, + } + + await ( + expect([ + 'pnpm', + [ + '--silent', + 'cli', + '-u', + provider.connection.rpcEndpoint, + '--program-id', + program.programId.toBase58(), + 'show-bond', + bondAccount.toBase58(), + '-f', + 'yaml', + ], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ]) as any + ).toHaveMatchingSpawnOutput({ + code: 0, + signal: '', + // stderr: '', + stdout: YAML.stringify(expectedData), + }) + + await ( + expect([ + 'pnpm', + [ + '--silent', + 'cli', + '-u', + provider.connection.rpcEndpoint, + '--program-id', + program.programId.toBase58(), + 'show-bond', + '--config', + configAccount.toBase58(), + '-f', + 'yaml', + ], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ]) as any + ).toHaveMatchingSpawnOutput({ + code: 0, + signal: '', + // stderr: '', + stdout: YAML.stringify([expectedData]), + }) + + await ( + expect([ + 'pnpm', + [ + '--silent', + 'cli', + '-u', + provider.connection.rpcEndpoint, + '--program-id', + program.programId.toBase58(), + 'show-bond', + '--validator-vote-account', + voteAccount.toBase58(), + '-f', + 'yaml', + ], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ]) as any + ).toHaveMatchingSpawnOutput({ + code: 0, + signal: '', + // stderr: '', + stdout: YAML.stringify([expectedData]), + }) + + await ( + expect([ + 'pnpm', + [ + '--silent', + 'cli', + '-u', + provider.connection.rpcEndpoint, + '--program-id', + program.programId.toBase58(), + 'show-bond', + '--bond-authority', + bondAuthority.publicKey.toBase58(), + '-f', + 'yaml', + ], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ]) as any + ).toHaveMatchingSpawnOutput({ + code: 0, + signal: '', + // stderr: '', + stdout: YAML.stringify([expectedData]), + }) + + await ( + expect([ + 'pnpm', + [ + '--silent', + 'cli', + '-u', + provider.connection.rpcEndpoint, + '--program-id', + program.programId.toBase58(), + 'show-bond', + '--config', + configAccount.toBase58(), + '--validator-vote-account', + voteAccount.toBase58(), + '--bond-authority', + bondAuthority.publicKey.toBase58(), + '-f', + 'yaml', + ], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ]) as any + ).toHaveMatchingSpawnOutput({ + code: 0, + signal: '', + // stderr: '', + stdout: YAML.stringify([expectedData]), + }) + + await ( + expect([ + 'pnpm', + [ + '--silent', + 'cli', + '-u', + provider.connection.rpcEndpoint, + '--program-id', + program.programId.toBase58(), + 'show-bond', + '--validator-vote-account', + Keypair.generate().publicKey, + '-f', + 'yaml', + ], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ]) as any + ).toHaveMatchingSpawnOutput({ + code: 0, + signal: '', + // stderr: '', + stdout: YAML.stringify([]), + }) + }) }) diff --git a/packages/validator-bonds-cli/__tests__/test-validator/utils.ts b/packages/validator-bonds-cli/__tests__/test-validator/utils.ts index edef5143..9bbc6dbb 100644 --- a/packages/validator-bonds-cli/__tests__/test-validator/utils.ts +++ b/packages/validator-bonds-cli/__tests__/test-validator/utils.ts @@ -1,19 +1,37 @@ -import * as anchor from '@coral-xyz/anchor' -import { AnchorProvider } from '@coral-xyz/anchor' +import { AnchorExtendedProvider } from '@marinade.finance/validator-bonds-sdk/__tests__/test-validator/testValidator' +import { createTempFileKeypair } from '@marinade.finance/web3js-common' import { - ValidatorBondsProgram, - getProgram, -} from '@marinade.finance/validator-bonds-sdk' + Keypair, + LAMPORTS_PER_SOL, + SystemProgram, + Transaction, +} from '@solana/web3.js' -export async function initTest(): Promise<{ - program: ValidatorBondsProgram - provider: AnchorProvider +export async function getRentPayer(provider: AnchorExtendedProvider): Promise<{ + path: string + cleanup: () => Promise + keypair: Keypair }> { - if (process.env.ANCHOR_PROVIDER_URL?.includes('localhost')) { - // workaround to: https://github.com/coral-xyz/anchor/pull/2725 - process.env.ANCHOR_PROVIDER_URL = 'http://127.0.0.1:8899' + const { + keypair: rentPayerKeypair, + path: rentPayerPath, + cleanup: cleanupRentPayer, + } = await createTempFileKeypair() + const rentPayerFunds = 10 * LAMPORTS_PER_SOL + const tx = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: provider.walletPubkey, + toPubkey: rentPayerKeypair.publicKey, + lamports: rentPayerFunds, + }) + ) + await provider.sendAndConfirm!(tx) + await expect( + provider.connection.getBalance(rentPayerKeypair.publicKey) + ).resolves.toStrictEqual(rentPayerFunds) + return { + keypair: rentPayerKeypair, + path: rentPayerPath, + cleanup: cleanupRentPayer, } - const provider = AnchorProvider.env() as anchor.AnchorProvider - provider.opts.skipPreflight = true - return { program: getProgram(provider), provider } } diff --git a/packages/validator-bonds-cli/package.json b/packages/validator-bonds-cli/package.json index fe17ae95..e4cb1caa 100644 --- a/packages/validator-bonds-cli/package.json +++ b/packages/validator-bonds-cli/package.json @@ -6,6 +6,9 @@ "type": "git", "url": "git@github.com:marinade-finance/validator-bonds.git" }, + "bin": { + "validator-bonds": "./src/index.js" + }, "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, @@ -20,9 +23,9 @@ "@marinade.finance/validator-bonds-sdk": "workspace:*", "@coral-xyz/anchor": "^0.29.0", "@solana/web3.js": "^1.87.6", - "@marinade.finance/cli-common": "^2.0.18", - "@marinade.finance/anchor-common": "^2.0.18", - "@marinade.finance/web3js-common": "^2.0.18", + "@marinade.finance/cli-common": "^2.0.20", + "@marinade.finance/anchor-common": "^2.0.20", + "@marinade.finance/web3js-common": "^2.0.20", "bn.js": "^5.2.1", "jsbi": "^4.3.0", "commander": "^9.5.0", @@ -32,6 +35,6 @@ "yaml": "^2.3.3" }, "devDependencies": { - "@marinade.finance/jest-utils": "^2.0.18" + "@marinade.finance/jest-utils": "^2.0.20" } } diff --git a/packages/validator-bonds-cli/src/commands/index.ts b/packages/validator-bonds-cli/src/commands/index.ts index b6ed3f68..6abc7db8 100644 --- a/packages/validator-bonds-cli/src/commands/index.ts +++ b/packages/validator-bonds-cli/src/commands/index.ts @@ -1,9 +1,10 @@ import { Command } from 'commander' import { installManage } from './manage' -import { installShowConfig, installShowEvent } from './show' +import { installShowConfig, installShowEvent, installShowBond } from './show' export function installCommands(program: Command) { installManage(program) installShowConfig(program) installShowEvent(program) + installShowBond(program) } diff --git a/packages/validator-bonds-cli/src/commands/manage/configureBond.ts b/packages/validator-bonds-cli/src/commands/manage/configureBond.ts new file mode 100644 index 00000000..9753e627 --- /dev/null +++ b/packages/validator-bonds-cli/src/commands/manage/configureBond.ts @@ -0,0 +1,132 @@ +import { parsePubkey, parsePubkeyOrKeypair } from '@marinade.finance/cli-common' +import { Keypair, PublicKey, Signer } from '@solana/web3.js' +import { Command } from 'commander' +import { setProgramIdByOwner } from '../../context' +import { transaction } from '@marinade.finance/anchor-common' +import { Wallet, executeTx } from '@marinade.finance/web3js-common' +import { + CONFIG_ADDRESS, + configureBondInstruction, +} from '@marinade.finance/validator-bonds-sdk' +import { toHundredsBps } from '@marinade.finance/validator-bonds-sdk/src/utils' + +export function installConfigureBond(program: Command) { + program + .command('configure-bond') + .description('Configure existing bond account.') + .argument( + '[bond-account-address]', + 'Address of the bond account to configure. ' + + 'When not provided the command requires defined --config and --vote-account options', + parsePubkey + ) + .option( + '--config ', + '(optional when the argument bond-account-address is provided)' + + 'The config account that the bond is created under ' + + `(default: ${CONFIG_ADDRESS.toBase58()})`, + parsePubkey + ) + .option( + '--vote-account ', + '(optional when the argument bond-account-address is provided)' + + 'Validator vote account that the bond is bound to', + parsePubkey + ) + .option( + '--authority ', + 'Authority that is permitted to do changes in bonds account. ' + + 'It is either the authority defined in bonds account or ' + + 'validator vote account withdrawer authority that the bond account is connected to. ' + + '(default: wallet keypair)', + parsePubkeyOrKeypair + ) + .option( + '--bond-authority ', + 'New value of authority that is permitted to operate with bond account.', + parsePubkey + ) + .option( + '--revenue-share ', + 'New value of the revenue share in percents (the precision is 1/10000 of the percent).', + toHundredsBps + ) + + .action( + async ( + bondAccountAddress: Promise, + { + config, + voteAccount, + authority, + bondAuthority, + revenueShare, + }: { + config?: Promise + voteAccount?: Promise + authority?: Promise + bondAuthority?: Promise + revenueShare?: number + } + ) => { + await manageConfigureBond({ + bondAccountAddress: await bondAccountAddress, + config: await config, + voteAccount: await voteAccount, + authority: await authority, + newBondAuthority: await bondAuthority, + newRevenueShareHundredthBps: revenueShare, + }) + } + ) +} + +async function manageConfigureBond({ + bondAccountAddress, + config = CONFIG_ADDRESS, + voteAccount, + authority, + newBondAuthority, + newRevenueShareHundredthBps, +}: { + bondAccountAddress?: PublicKey + config?: PublicKey + voteAccount?: PublicKey + authority?: PublicKey | Keypair + newBondAuthority?: PublicKey + newRevenueShareHundredthBps?: number +}) { + const { program, provider, logger, simulate, printOnly, wallet } = + await setProgramIdByOwner(config) + + const tx = await transaction(provider) + const signers: (Signer | Wallet)[] = [wallet] + + authority = authority || wallet.publicKey + if (authority instanceof Keypair) { + signers.push(authority) + authority = authority.publicKey + } + + const { instruction, bondAccount } = await configureBondInstruction({ + program, + bondAccount: bondAccountAddress, + configAccount: config, + validatorVoteAccount: voteAccount, + authority, + newRevenueShareHundredthBps, + newBondAuthority, + }) + tx.add(instruction) + + await executeTx({ + connection: provider.connection, + transaction: tx, + errMessage: `'Failed to configure bond account ${bondAccount.toBase58()}`, + signers, + logger, + simulate, + printOnly, + }) + logger.info(`Bond account ${bondAccount.toBase58()} successfully configured`) +} diff --git a/packages/validator-bonds-cli/src/commands/manage/configureConfig.ts b/packages/validator-bonds-cli/src/commands/manage/configureConfig.ts new file mode 100644 index 00000000..36a116e9 --- /dev/null +++ b/packages/validator-bonds-cli/src/commands/manage/configureConfig.ts @@ -0,0 +1,136 @@ +import { parsePubkey, parsePubkeyOrKeypair } from '@marinade.finance/cli-common' +import { Keypair, PublicKey, Signer } from '@solana/web3.js' +import { Command } from 'commander' +import { setProgramIdByOwner } from '../../context' +import { transaction } from '@marinade.finance/anchor-common' +import { Wallet, executeTx } from '@marinade.finance/web3js-common' +import { + CONFIG_ADDRESS, + configureConfigInstruction, +} from '@marinade.finance/validator-bonds-sdk' + +export function installConfigureConfig(program: Command) { + program + .command('configure-config') + .description('Configure existing config account.') + .argument( + '[config-account-address]', + 'Address of the validator bonds config account to configure ' + + `(default: ${CONFIG_ADDRESS.toBase58()})`, + parsePubkey + ) + .option( + '--admin-authority ', + 'Admin authority that is permitted to do the configuration change (default: wallet)', + parsePubkeyOrKeypair + ) + .option( + '--operator ', + 'New operator authority to be configured', + parsePubkey + ) + .option( + '--admin ', + 'New admin authority to be configured', + parsePubkey + ) + .option( + '--epochs-to-claim-settlement ', + 'New number of epochs after which claim can be settled', + parseFloat + ) + .option( + '--withdraw-lockup-epochs ', + 'New number of epochs after which withdraw can be executed', + parseFloat + ) + .option( + '--minimum-stake-lamports ', + 'New value of minimum stake lamports used when program do splitting of stake', + parseFloat + ) + .action( + async ( + configAccountAddress: Promise, + { + adminAuthority, + admin, + operator, + epochsToClaimSettlement, + withdrawLockupEpochs, + minimumStakeLamports, + }: { + adminAuthority?: Promise + admin?: Promise + operator?: Promise + rentPayer?: Promise + epochsToClaimSettlement?: number + withdrawLockupEpochs?: number + minimumStakeLamports?: number + } + ) => { + await manageConfigureConfig({ + address: await configAccountAddress, + adminAuthority: await adminAuthority, + admin: await admin, + operator: await operator, + epochsToClaimSettlement, + withdrawLockupEpochs, + minimumStakeLamports, + }) + } + ) +} + +async function manageConfigureConfig({ + address = CONFIG_ADDRESS, + adminAuthority, + admin, + operator, + epochsToClaimSettlement, + withdrawLockupEpochs, + minimumStakeLamports, +}: { + address?: PublicKey + adminAuthority?: Keypair | PublicKey + admin?: PublicKey + operator?: PublicKey + epochsToClaimSettlement?: number + withdrawLockupEpochs?: number + minimumStakeLamports?: number +}) { + const { program, provider, logger, simulate, printOnly, wallet } = + await setProgramIdByOwner(address) + + const tx = await transaction(provider) + const signers: (Signer | Wallet)[] = [wallet] + + adminAuthority = adminAuthority || wallet.publicKey + if (adminAuthority instanceof Keypair) { + signers.push(adminAuthority) + adminAuthority = adminAuthority.publicKey + } + + const { instruction } = await configureConfigInstruction({ + program, + configAccount: address, + adminAuthority, + newAdmin: admin, + newOperator: operator, + newEpochsToClaimSettlement: epochsToClaimSettlement, + newWithdrawLockupEpochs: withdrawLockupEpochs, + newMinimumStakeLamports: minimumStakeLamports, + }) + tx.add(instruction) + + await executeTx({ + connection: provider.connection, + transaction: tx, + errMessage: `'Failed to create config account ${address.toBase58()}`, + signers, + logger, + simulate, + printOnly, + }) + logger.info(`Config account ${address.toBase58()} successfully configured`) +} diff --git a/packages/validator-bonds-cli/src/commands/manage/index.ts b/packages/validator-bonds-cli/src/commands/manage/index.ts index b0bbd3a0..1c45c0bc 100644 --- a/packages/validator-bonds-cli/src/commands/manage/index.ts +++ b/packages/validator-bonds-cli/src/commands/manage/index.ts @@ -1,6 +1,12 @@ import { Command } from 'commander' import { installInitConfig } from './initConfig' +import { installConfigureConfig } from './configureConfig' +import { installInitBond } from './initBond' +import { installConfigureBond } from './configureBond' export function installManage(program: Command) { installInitConfig(program) + installConfigureConfig(program) + installInitBond(program) + installConfigureBond(program) } diff --git a/packages/validator-bonds-cli/src/commands/manage/initBond.ts b/packages/validator-bonds-cli/src/commands/manage/initBond.ts new file mode 100644 index 00000000..d79c2ddb --- /dev/null +++ b/packages/validator-bonds-cli/src/commands/manage/initBond.ts @@ -0,0 +1,138 @@ +import { parsePubkey, parsePubkeyOrKeypair } from '@marinade.finance/cli-common' +import { Keypair, PublicKey, Signer } from '@solana/web3.js' +import { Command } from 'commander' +import { setProgramIdByOwner } from '../../context' +import { transaction } from '@marinade.finance/anchor-common' +import { Wallet, executeTx } from '@marinade.finance/web3js-common' +import { + CONFIG_ADDRESS, + initBondInstruction, +} from '@marinade.finance/validator-bonds-sdk' +import { toHundredsBps } from '@marinade.finance/validator-bonds-sdk/src/utils' + +export function installInitBond(program: Command) { + program + .command('init-bond') + .description('Create a new bond account.') + .option( + '--config ', + 'Validator Bond config account that the bond is created under ' + + `(default: ${CONFIG_ADDRESS.toBase58()})`, + parsePubkey + ) + .requiredOption( + '--vote-account ', + 'Validator vote account that this bond is bound to', + parsePubkey + ) + .option( + '--vote-account-withdrawer ', + 'Validator vote account withdrawer authority. ' + + 'To create the bond the signature of the account is needed (default: wallet keypair)', + parsePubkeyOrKeypair + ) + .option( + '--bond-authority ', + 'Authority that is permitted to operate with bond account (default: wallet pubkey)', + parsePubkey + ) + .option( + '--revenue-share ', + 'Revenue share in percents (the precision is 1/10000 of the percent)', + toHundredsBps, + 0 + ) + .option( + '--rent-payer ', + 'Rent payer for the account creation (default: wallet keypair)', + parsePubkeyOrKeypair + ) + + .action( + async ({ + config, + voteAccount, + voteAccountWithdrawer, + bondAuthority, + revenueShare, + rentPayer, + }: { + config?: Promise + voteAccount: Promise + voteAccountWithdrawer?: Promise + bondAuthority: Promise + revenueShare: number + rentPayer?: Promise + }) => { + await manageInitBond({ + config: await config, + voteAccount: await voteAccount, + voteAccountWithdrawer: await voteAccountWithdrawer, + bondAuthority: await bondAuthority, + revenueShare: revenueShare, + rentPayer: await rentPayer, + }) + } + ) +} + +async function manageInitBond({ + config = CONFIG_ADDRESS, + voteAccount, + voteAccountWithdrawer, + bondAuthority, + revenueShare, + rentPayer, +}: { + config?: PublicKey + voteAccount: PublicKey + voteAccountWithdrawer?: PublicKey | Keypair + bondAuthority: PublicKey + revenueShare: number + rentPayer?: PublicKey | Keypair +}) { + const { program, provider, logger, simulate, printOnly, wallet } = + await setProgramIdByOwner(config) + + const tx = await transaction(provider) + const signers: (Signer | Wallet)[] = [wallet] + + rentPayer = rentPayer || wallet.publicKey + if (rentPayer instanceof Keypair) { + signers.push(rentPayer) + rentPayer = rentPayer.publicKey + } + voteAccountWithdrawer = voteAccountWithdrawer || wallet.publicKey + if (voteAccountWithdrawer instanceof Keypair) { + signers.push(voteAccountWithdrawer) + voteAccountWithdrawer = voteAccountWithdrawer.publicKey + } + + bondAuthority = bondAuthority || wallet.publicKey + + const { instruction, bondAccount } = await initBondInstruction({ + program, + configAccount: config, + bondAuthority, + validatorVoteAccount: voteAccount, + validatorVoteWithdrawer: voteAccountWithdrawer, + revenueShareHundredthBps: revenueShare, + rentPayer, + }) + tx.add(instruction) + + await executeTx({ + connection: provider.connection, + transaction: tx, + errMessage: + `'Failed to init bond account ${bondAccount.toBase58()}` + + ` of config ${config.toBase58()}`, + signers, + logger, + simulate, + printOnly, + }) + logger.info( + `Bond account ${bondAccount.toBase58()} of config ${config.toBase58()} successfully created` + ) +} diff --git a/packages/validator-bonds-cli/src/commands/manage/initConfig.ts b/packages/validator-bonds-cli/src/commands/manage/initConfig.ts index b29d40aa..27894e44 100644 --- a/packages/validator-bonds-cli/src/commands/manage/initConfig.ts +++ b/packages/validator-bonds-cli/src/commands/manage/initConfig.ts @@ -64,8 +64,8 @@ export function installInitConfig(program: Command) { }) => { await manageInitConfig({ address: await address, - adminAuthority: await admin, - operatorAuthority: await operator, + admin: await admin, + operator: await operator, rentPayer: await rentPayer, epochsToClaimSettlement, withdrawLockupEpochs, @@ -76,15 +76,15 @@ export function installInitConfig(program: Command) { async function manageInitConfig({ address = Keypair.generate(), - adminAuthority, - operatorAuthority, + admin, + operator, rentPayer, epochsToClaimSettlement, withdrawLockupEpochs, }: { address?: Keypair - adminAuthority?: PublicKey - operatorAuthority?: PublicKey + admin?: PublicKey + operator?: PublicKey rentPayer?: PublicKey | Keypair epochsToClaimSettlement: number withdrawLockupEpochs: number @@ -101,19 +101,18 @@ async function manageInitConfig({ rentPayer = rentPayer.publicKey } - adminAuthority = adminAuthority || wallet.publicKey - operatorAuthority = operatorAuthority || adminAuthority + admin = admin || wallet.publicKey + operator = operator || admin const { instruction } = await initConfigInstruction({ configAccount: address.publicKey, program, - adminAuthority, - operatorAuthority, + admin, + operator, epochsToClaimSettlement, withdrawLockupEpochs, rentPayer, }) - console.dir(instruction) tx.add(instruction) await executeTx({ diff --git a/packages/validator-bonds-cli/src/commands/show.ts b/packages/validator-bonds-cli/src/commands/show.ts index a3f928b5..51f27239 100644 --- a/packages/validator-bonds-cli/src/commands/show.ts +++ b/packages/validator-bonds-cli/src/commands/show.ts @@ -9,21 +9,28 @@ import { } from '@marinade.finance/cli-common' import { PublicKey } from '@solana/web3.js' import { Command } from 'commander' -import { getCliContext } from '../context' +import { getCliContext, setProgramIdByOwner } from '../context' import { + Bond, Config, + findBonds, findConfigs, + getBond, getConfig, } from '@marinade.finance/validator-bonds-sdk' import { ProgramAccount } from '@coral-xyz/anchor' +export type ProgramAccountWithProgramId = ProgramAccount & { + programId: PublicKey +} + export function installShowConfig(program: Command) { program .command('show-config') - .description('Showing data of config account') + .description('Showing data of config account(s)') .argument( '[address]', - 'Address of the config account to show (when argument is provided other filter options are ignored)', + 'Address of the config account to show (when the argument is provided other filter options are ignored)', parsePubkey ) .option( @@ -64,8 +71,59 @@ export function installShowConfig(program: Command) { ) } -export type ProgramAccountWithProgramId = ProgramAccount & { - programId: PublicKey +export function installShowBond(program: Command) { + program + .command('show-bond') + .description('Showing data of bond account(s)') + .argument( + '[address]', + 'Address of the bond account to show (when the argument is provided other filter options are ignored)', + parsePubkey + ) + .option( + '--config ', + 'Config account to filter the bond accounts with', + parsePubkey + ) + .option( + '--validator-vote-account ', + 'Validator vote account to filter the bond accounts with', + parsePubkey + ) + .option( + '--bond-authority ', + 'Bond authority to filter the bond accounts with', + parsePubkey + ) + .option( + `-f, --format <${FORMAT_TYPE_DEF.join('|')}>`, + 'Format of output', + 'text' + ) + .action( + async ( + address: Promise, + { + config, + validatorVoteAccount, + bondAuthority, + format, + }: { + config?: Promise + validatorVoteAccount?: Promise + bondAuthority?: Promise + format: FormatType + } + ) => { + await showBond({ + address: await address, + config: await config, + validatorVoteAccount: await validatorVoteAccount, + bondAuthority: await bondAuthority, + format, + }) + } + ) } export function installShowEvent(program: Command) { @@ -92,7 +150,7 @@ async function showConfig({ operatorAuthority?: PublicKey format: FormatType }) { - const { program } = getCliContext() + const { program } = await setProgramIdByOwner(address) // CLI provided an address, we will search for that one account let data: @@ -141,6 +199,68 @@ async function showConfig({ print_data(reformatted, format) } +async function showBond({ + address, + config, + validatorVoteAccount, + bondAuthority, + format, +}: { + address?: PublicKey + config?: PublicKey + validatorVoteAccount?: PublicKey + bondAuthority?: PublicKey + format: FormatType +}) { + const { program } = await setProgramIdByOwner(address) + + let data: + | ProgramAccountWithProgramId + | ProgramAccountWithProgramId[] + if (address) { + try { + const bondData = await getBond(program, address) + data = { + programId: program.programId, + publicKey: address, + account: bondData, + } + } catch (e) { + throw new CliCommandError({ + valueName: '--address', + value: address.toBase58(), + msg: 'Failed to fetch bond account data', + cause: e as Error, + }) + } + } else { + // CLI did not provide an address, we will search for accounts based on filter parameters + try { + const foundData = await findBonds({ + program, + config, + validatorVoteAccount, + bondAuthority, + }) + data = foundData.map(bondData => ({ + programId: program.programId, + publicKey: bondData.publicKey, + account: bondData.account, + })) + } catch (err) { + throw new CliCommandError({ + valueName: '--config|--validator-vote-account|--bond-authority', + value: `${config?.toBase58()}}|${validatorVoteAccount?.toBase58()}|${bondAuthority?.toBase58()}}`, + msg: 'Error while fetching bond account based on filter parameters', + cause: err as Error, + }) + } + } + + const reformatted = reformat(data, reformatReserved) + print_data(reformatted, format) +} + async function showEvent({ eventData }: { eventData: string }) { const { program } = getCliContext() diff --git a/packages/validator-bonds-cli/src/context.ts b/packages/validator-bonds-cli/src/context.ts index 24eb829e..b723bb1c 100644 --- a/packages/validator-bonds-cli/src/context.ts +++ b/packages/validator-bonds-cli/src/context.ts @@ -1,6 +1,6 @@ import { Connection, Keypair, PublicKey } from '@solana/web3.js' import { Logger } from 'pino' -import { Provider, Wallet } from '@coral-xyz/anchor' +import { AnchorProvider, Provider, Wallet } from '@coral-xyz/anchor' import { Context, getClusterUrl, @@ -11,15 +11,15 @@ import { import { Wallet as WalletInterface } from '@marinade.finance/web3js-common' import { ValidatorBondsProgram, - getProgram, + getProgram as getValidatorBondsProgram, } from '@marinade.finance/validator-bonds-sdk' export class ValidatorBondsCliContext extends Context { - readonly program: ValidatorBondsProgram + private bondsProgramId?: PublicKey readonly provider: Provider constructor({ - program, + programId, provider, wallet, logger, @@ -28,7 +28,7 @@ export class ValidatorBondsCliContext extends Context { printOnly, commandName, }: { - program: ValidatorBondsProgram + programId?: PublicKey provider: Provider wallet: WalletInterface logger: Logger @@ -39,7 +39,22 @@ export class ValidatorBondsCliContext extends Context { }) { super({ wallet, logger, skipPreflight, simulate, printOnly, commandName }) this.provider = provider - this.program = program + this.bondsProgramId = programId + } + + set programId(programId: PublicKey | undefined) { + this.bondsProgramId = programId + } + + get programId(): PublicKey | undefined { + return this.bondsProgramId + } + + get program(): ValidatorBondsProgram { + return getValidatorBondsProgram({ + connection: this.provider, + programId: this.bondsProgramId, + }) } } @@ -56,7 +71,7 @@ export function setValidatorBondsCliContext({ }: { cluster: string walletKeypair: Keypair - programId: PublicKey + programId?: PublicKey simulate: boolean printOnly: boolean skipPreflight: boolean @@ -68,17 +83,11 @@ export function setValidatorBondsCliContext({ const parsedCommitment = parseCommitment(commitment) const connection = new Connection(getClusterUrl(cluster), parsedCommitment) const wallet = new Wallet(walletKeypair) - const program = getProgram({ - connection, - wallet, - opts: { skipPreflight }, - programId, - }) - const provider = program.provider as Provider + const provider = new AnchorProvider(connection, wallet, { skipPreflight }) setContext( new ValidatorBondsCliContext({ - program, + programId, provider, wallet, logger, @@ -94,6 +103,27 @@ export function setValidatorBondsCliContext({ } } +// Configures the CLI validator bonds program id but only when it's not setup already. +// It searches for owner of the provided account and sets the programId as its owner. +export async function setProgramIdByOwner( + accountPubkey?: PublicKey +): Promise { + const cliContext = getCliContext() + if (cliContext.programId === undefined && accountPubkey !== undefined) { + const accountInfo = await cliContext.provider.connection.getAccountInfo( + accountPubkey + ) + if (accountInfo === null) { + throw new Error( + `setProgramIdByOwner: account ${accountPubkey.toBase58()} does not exist` + + ` on cluster ${cliContext.provider.connection.rpcEndpoint}` + ) + } + cliContext.programId = accountInfo.owner + } + return cliContext +} + export function getCliContext(): ValidatorBondsCliContext { return getContext() as ValidatorBondsCliContext } diff --git a/packages/validator-bonds-cli/src/index.ts b/packages/validator-bonds-cli/src/index.ts index 1a6cc1f1..881569e9 100644 --- a/packages/validator-bonds-cli/src/index.ts +++ b/packages/validator-bonds-cli/src/index.ts @@ -32,9 +32,8 @@ program ) .option( '--program-id ', - `Program id of directed stake contract (default: ${VALIDATOR_BONDS_PROGRAM_ID})`, - parsePubkey, - Promise.resolve(VALIDATOR_BONDS_PROGRAM_ID) + `Program id of validator bonds contract (default: ${VALIDATOR_BONDS_PROGRAM_ID})`, + parsePubkey ) .option('-s, --simulate', 'Simulate', false) .option( diff --git a/packages/validator-bonds-sdk/README.md b/packages/validator-bonds-sdk/README.md new file mode 100644 index 00000000..6e883f37 --- /dev/null +++ b/packages/validator-bonds-sdk/README.md @@ -0,0 +1,7 @@ +# Validator Bonds SDK + +SDK based on top of the Anchor Program IDL. Anchor IDL wrapper. + +* To read account data see ./api.ts +* To import types and read PDA addresses see ./sdk.ts +* To execute contract operations see ./instructions/* \ No newline at end of file diff --git a/packages/validator-bonds-sdk/__tests__/bankrun/bankrun.ts b/packages/validator-bonds-sdk/__tests__/bankrun/bankrun.ts new file mode 100644 index 00000000..ba4d0495 --- /dev/null +++ b/packages/validator-bonds-sdk/__tests__/bankrun/bankrun.ts @@ -0,0 +1,115 @@ +import { Wallet as WalletInterface } from '@coral-xyz/anchor/dist/cjs/provider' +import { ValidatorBondsProgram, getProgram } from '../../src' +import { BanksTransactionMeta, startAnchor } from 'solana-bankrun' +import { BankrunProvider } from 'anchor-bankrun' +import { + PublicKey, + Signer, + Transaction, + TransactionInstruction, + TransactionInstructionCtorFields, +} from '@solana/web3.js' +import { instanceOfWallet } from '@marinade.finance/web3js-common' +import { ExtendedProvider } from '../utils/provider' + +export class BankrunExtendedProvider + extends BankrunProvider + implements ExtendedProvider +{ + async sendIx( + signers: (WalletInterface | Signer)[], + ...ixes: ( + | Transaction + | TransactionInstruction + | TransactionInstructionCtorFields + )[] + ): Promise { + const tx = await bankrunTransaction(this) + tx.add(...ixes) + await bankrunExecute(this, [this.wallet, ...signers], tx) + } + + get walletPubkey(): PublicKey { + return this.wallet.publicKey + } +} + +export async function initBankrunTest(programId?: PublicKey): Promise<{ + program: ValidatorBondsProgram + provider: BankrunExtendedProvider +}> { + const context = await startAnchor('./', [], []) + const provider = new BankrunExtendedProvider(context) + return { + program: getProgram({ connection: provider, programId }), + provider, + } +} + +export async function bankrunTransaction( + provider: BankrunProvider +): Promise { + const bh = await provider.context.banksClient.getLatestBlockhash() + const lastValidBlockHeight = ( + bh === null ? Number.MAX_VALUE : bh[1] + ) as number + return new Transaction({ + feePayer: provider.wallet.publicKey, + blockhash: provider.context.lastBlockhash, + lastValidBlockHeight, + }) +} + +export async function bankrunExecuteIx( + provider: BankrunProvider, + signers: (WalletInterface | Signer)[], + ...ixes: ( + | Transaction + | TransactionInstruction + | TransactionInstructionCtorFields + )[] +): Promise { + const tx = await bankrunTransaction(provider) + tx.add(...ixes) + return await bankrunExecute(provider, signers, tx) +} + +export async function bankrunExecute( + provider: BankrunProvider, + signers: (WalletInterface | Signer)[], + tx: Transaction +): Promise { + for (const signer of signers) { + if (instanceOfWallet(signer)) { + await signer.signTransaction(tx) + } else { + tx.partialSign(signer) + } + } + return await provider.context.banksClient.processTransaction(tx) +} + +export async function assertNotExist( + provider: BankrunProvider, + account: PublicKey +) { + const accountInfo = await provider.context.banksClient.getAccount(account) + expect(accountInfo).toBeNull() +} + +// https://github.com/solana-labs/solana/blob/v1.17.7/sdk/program/src/epoch_schedule.rs#L29C1-L29C45 +export const MINIMUM_SLOTS_PER_EPOCH = 32 +// https://github.com/solana-labs/solana/blob/v1.17.7/sdk/program/src/epoch_schedule.rs#L167 +export function warpToEpoch(provider: BankrunProvider, epoch: number) { + const epochBigInt = BigInt(epoch) + const { slotsPerEpoch, firstNormalEpoch, firstNormalSlot } = + provider.context.genesisConfig.epochSchedule + let warpToEpoch: bigint + if (epochBigInt <= firstNormalEpoch) { + warpToEpoch = BigInt(((2 ^ epoch) - 1) * MINIMUM_SLOTS_PER_EPOCH) + } else { + warpToEpoch = + (epochBigInt - firstNormalEpoch) * slotsPerEpoch + firstNormalSlot + } + provider.context.warpToSlot(warpToEpoch) +} diff --git a/packages/validator-bonds-sdk/__tests__/bankrun/configureBond.spec.ts b/packages/validator-bonds-sdk/__tests__/bankrun/configureBond.spec.ts new file mode 100644 index 00000000..b370eed2 --- /dev/null +++ b/packages/validator-bonds-sdk/__tests__/bankrun/configureBond.spec.ts @@ -0,0 +1,136 @@ +import { + Bond, + Config, + ValidatorBondsProgram, + configureBondInstruction, + getBond, + getConfig, +} from '../../src' +import { BankrunExtendedProvider, initBankrunTest } from './bankrun' +import { + executeInitBondInstruction, + executeInitConfigInstruction, +} from '../utils/testTransactions' +import { ProgramAccount } from '@coral-xyz/anchor' +import { Keypair } from '@solana/web3.js' +import { createVoteAccount } from '../utils/staking' +import { checkAnchorErrorMessage } from '../utils/helpers' + +describe('Validator Bonds configure bond account', () => { + let provider: BankrunExtendedProvider + let program: ValidatorBondsProgram + let config: ProgramAccount + let bond: ProgramAccount + let bondAuthority: Keypair + let withdrawerAuthority: Keypair + let voterAuthority: Keypair + + beforeAll(async () => { + ;({ provider, program } = await initBankrunTest()) + }) + + beforeEach(async () => { + const { configAccount } = await executeInitConfigInstruction({ + program, + provider, + }) + config = { + publicKey: configAccount, + account: await getConfig(program, configAccount), + } + const { voteAccount, authorizedWithdrawer, authorizedVoter } = + await createVoteAccount(provider) + bondAuthority = Keypair.generate() + const { bondAccount } = await executeInitBondInstruction( + program, + provider, + config.publicKey, + bondAuthority, + voteAccount, + authorizedWithdrawer, + 123 + ) + bond = { + publicKey: bondAccount, + account: await getBond(program, bondAccount), + } + withdrawerAuthority = authorizedWithdrawer + voterAuthority = authorizedVoter + }) + + it('configures bond with bond authority and then back', async () => { + const newBondAuthority = Keypair.generate() + const { instruction: ix1 } = await configureBondInstruction({ + program, + bondAccount: bond.publicKey, + authority: bondAuthority, + newBondAuthority: newBondAuthority.publicKey, + newRevenueShareHundredthBps: 321, + }) + await provider.sendIx([bondAuthority], ix1) + + let bondData = await getBond(program, bond.publicKey) + expect(bondData.config).toEqual(config.publicKey) + expect(bondData.authority).toEqual(newBondAuthority.publicKey) + expect(bondData.revenueShare).toEqual({ hundredthBps: 321 }) + + const { instruction: ix2 } = await configureBondInstruction({ + program, + bondAccount: bond.publicKey, + authority: newBondAuthority.publicKey, + newBondAuthority: bondAuthority.publicKey, + }) + await provider.sendIx([newBondAuthority], ix2) + + bondData = await getBond(program, bond.publicKey) + expect(bondData.authority).toEqual(bondAuthority.publicKey) + }) + + it('configures bond with withdrawer authority', async () => { + const newBondAuthority = Keypair.generate() + const { instruction } = await configureBondInstruction({ + program, + bondAccount: bond.publicKey, + authority: withdrawerAuthority, + newBondAuthority: newBondAuthority.publicKey, + }) + await provider.sendIx([withdrawerAuthority], instruction) + + const bondData = await getBond(program, bond.publicKey) + expect(bondData.config).toEqual(config.publicKey) + expect(bondData.authority).toEqual(newBondAuthority.publicKey) + }) + + it('fails to configure with voter authority', async () => { + const newBondAuthority = Keypair.generate() + const { instruction } = await configureBondInstruction({ + program, + configAccount: config.publicKey, + validatorVoteAccount: bond.account.validatorVoteAccount, + authority: voterAuthority, + newBondAuthority: newBondAuthority.publicKey, + }) + try { + await provider.sendIx([voterAuthority], instruction) + throw new Error('failure expected as wrong admin') + } catch (e) { + checkAnchorErrorMessage(e, 6016, 'Wrong authority') + } + }) + + it('fails to configure with a random authority', async () => { + const newBondAuthority = Keypair.generate() + const { instruction } = await configureBondInstruction({ + program, + bondAccount: bond.publicKey, + authority: newBondAuthority, + newBondAuthority: newBondAuthority.publicKey, + }) + try { + await provider.sendIx([newBondAuthority], instruction) + throw new Error('failure expected as wrong admin') + } catch (e) { + checkAnchorErrorMessage(e, 6016, 'Wrong authority') + } + }) +}) diff --git a/packages/validator-bonds-sdk/__tests__/bankrun/configureConfig.spec.ts b/packages/validator-bonds-sdk/__tests__/bankrun/configureConfig.spec.ts new file mode 100644 index 00000000..a4e9f645 --- /dev/null +++ b/packages/validator-bonds-sdk/__tests__/bankrun/configureConfig.spec.ts @@ -0,0 +1,135 @@ +import { + Config, + ValidatorBondsProgram, + getConfig, + configureConfigInstruction, +} from '../../src' +import { + BankrunExtendedProvider, + bankrunExecute, + bankrunExecuteIx, + bankrunTransaction, + initBankrunTest, +} from './bankrun' +import { ProgramAccount } from '@coral-xyz/anchor' +import { Keypair, PublicKey, Transaction } from '@solana/web3.js' +import { executeInitConfigInstruction } from '../utils/testTransactions' +import { checkAnchorErrorMessage } from '../utils/helpers' + +describe('Validator Bonds configure config tests', () => { + let provider: BankrunExtendedProvider + let program: ValidatorBondsProgram + let configInitialized: ProgramAccount + let adminAuthority: Keypair + let operatorAuthority: Keypair + + beforeAll(async () => { + ;({ provider, program } = await initBankrunTest()) + }) + + beforeEach(async () => { + const { + configAccount, + adminAuthority: adminAuth, + operatorAuthority: operatorAuth, + } = await executeInitConfigInstruction({ + program, + provider, + epochsToClaimSettlement: 1, + withdrawLockupEpochs: 2, + }) + configInitialized = { + publicKey: configAccount, + account: await getConfig(program, configAccount), + } + expect(configInitialized.account.adminAuthority).toEqual( + adminAuth.publicKey + ) + expect(configInitialized.account.epochsToClaimSettlement).toEqual(1) + expect(configInitialized.account.withdrawLockupEpochs).toEqual(2) + adminAuthority = adminAuth + operatorAuthority = operatorAuth + }) + + it('configure config', async () => { + const newAdminAuthority = Keypair.generate() + const { instruction } = await configureConfigInstruction({ + program, + configAccount: configInitialized.publicKey, + adminAuthority: configInitialized.account.adminAuthority, + newEpochsToClaimSettlement: 3, + newAdmin: newAdminAuthority.publicKey, + }) + await bankrunExecuteIx( + provider, + [provider.wallet, adminAuthority], + instruction + ) + const config = await getConfig(program, configInitialized.publicKey) + expect(config.adminAuthority).toEqual(newAdminAuthority.publicKey) + expect(config.operatorAuthority).toEqual( + configInitialized.account.operatorAuthority + ) + expect(config.epochsToClaimSettlement).toEqual(3) + expect(config.withdrawLockupEpochs).toEqual( + configInitialized.account.withdrawLockupEpochs + ) + + const { instruction: instruction2 } = await configureConfigInstruction({ + program, + configAccount: configInitialized.publicKey, + newEpochsToClaimSettlement: 3, + newWithdrawLockupEpochs: 4, + newOperator: PublicKey.default, + }) + await bankrunExecuteIx( + provider, + [provider.wallet, newAdminAuthority], + instruction2 + ) + const config2 = await getConfig(program, configInitialized.publicKey) + expect(config2.adminAuthority).toEqual(newAdminAuthority.publicKey) + expect(config2.operatorAuthority).toEqual(PublicKey.default) + expect(config2.epochsToClaimSettlement).toEqual(3) + expect(config2.withdrawLockupEpochs).toEqual(4) + }) + + it('configure config wrong keys', async () => { + // wrong admin authority + const randomKey = Keypair.generate() + const tx = await getConfigureConfigTx(randomKey.publicKey) + try { + await bankrunExecute(provider, [provider.wallet, randomKey], tx) + } catch (e) { + checkAnchorErrorMessage(e, 6001, 'requires admin authority') + } + + // trying to use operator authority + const txOperator = await getConfigureConfigTx(operatorAuthority.publicKey) + try { + await bankrunExecuteIx( + provider, + [provider.wallet, operatorAuthority], + txOperator + ) + throw new Error('failure expected as wrong admin') + } catch (e) { + checkAnchorErrorMessage(e, 6001, 'requires admin authority') + } + }) + + async function getConfigureConfigTx( + adminAuthority?: PublicKey + ): Promise { + const tx = await bankrunTransaction(provider) + const { instruction } = await configureConfigInstruction({ + program, + adminAuthority, + configAccount: configInitialized.publicKey, + newWithdrawLockupEpochs: 42, + }) + tx.add(instruction) + provider.wallet.signTransaction(tx) + return tx + } +}) diff --git a/packages/validator-bonds-sdk/__tests__/bankrun/fundBond.spec.ts b/packages/validator-bonds-sdk/__tests__/bankrun/fundBond.spec.ts new file mode 100644 index 00000000..c4f06a2b --- /dev/null +++ b/packages/validator-bonds-sdk/__tests__/bankrun/fundBond.spec.ts @@ -0,0 +1,204 @@ +import { + Bond, + Config, + ValidatorBondsProgram, + fundBondInstruction, + getBond, + getConfig, + withdrawerAuthority, +} from '../../src' +import { + BankrunExtendedProvider, + initBankrunTest, + warpToEpoch, +} from './bankrun' +import { + executeInitBondInstruction, + executeInitConfigInstruction, +} from '../utils/testTransactions' +import { ProgramAccount } from '@coral-xyz/anchor' +import { Keypair, LAMPORTS_PER_SOL, PublicKey } from '@solana/web3.js' +import { + StakeStates, + createVoteAccount, + delegatedStakeAccount, + getAndCheckStakeAccount, + initializedStakeAccount, +} from '../utils/staking' +import { checkAnchorErrorMessage, signer } from '../utils/helpers' +import { BN } from 'bn.js' + +describe('Validator Bonds fund bond account', () => { + let provider: BankrunExtendedProvider + let program: ValidatorBondsProgram + let config: ProgramAccount + let bond: ProgramAccount + let bondAuthority: Keypair + const startUpEpoch = Math.floor(Math.random() * 100) + 100 + + beforeAll(async () => { + ;({ provider, program } = await initBankrunTest()) + warpToEpoch(provider, startUpEpoch) + }) + + beforeEach(async () => { + const { configAccount } = await executeInitConfigInstruction({ + program, + provider, + }) + config = { + publicKey: configAccount, + account: await getConfig(program, configAccount), + } + const { voteAccount, authorizedWithdrawer } = await createVoteAccount( + provider + ) + bondAuthority = Keypair.generate() + const { bondAccount } = await executeInitBondInstruction( + program, + provider, + config.publicKey, + bondAuthority, + voteAccount, + authorizedWithdrawer, + 123 + ) + bond = { + publicKey: bondAccount, + account: await getBond(program, bondAccount), + } + }) + + it('cannot fund with non-delegated stake account', async () => { + const { stakeAccount: nonDelegatedStakeAccount, withdrawer } = + await initializedStakeAccount(provider) + const { instruction } = await fundBondInstruction({ + program, + configAccount: config.publicKey, + bondAccount: bond.publicKey, + stakeAccount: nonDelegatedStakeAccount, + authority: withdrawer, + }) + try { + await provider.sendIx([signer(withdrawer)], instruction) + throw new Error('failure expected as not delegated') + } catch (e) { + checkAnchorErrorMessage(e, 6017, 'cannot be used for bonds') + } + }) + + it('cannot fund bond non activated with wrong delegation', async () => { + // random vote account is generated on the call of method delegatedStakeAccount + const { stakeAccount, withdrawer } = await delegatedStakeAccount({ + provider, + lamports: LAMPORTS_PER_SOL * 2, + }) + const { instruction } = await fundBondInstruction({ + program, + configAccount: config.publicKey, + bondAccount: bond.publicKey, + stakeAccount, + authority: withdrawer, + }) + try { + await provider.sendIx([withdrawer], instruction) + throw new Error('failure expected as not activated') + } catch (e) { + checkAnchorErrorMessage(e, 6023, 'Stake account is not fully activated') + } + + const nextEpoch = + Number((await provider.context.banksClient.getClock()).epoch) + 1 + warpToEpoch(provider, nextEpoch) + try { + await provider.sendIx([withdrawer], instruction) + } catch (e) { + checkAnchorErrorMessage(e, 6018, 'delegated to a wrong validator') + } + }) + + it('cannot fund bond with lockup delegation', async () => { + const nextEpoch = + Number((await provider.context.banksClient.getClock()).epoch) + 1 + const { stakeAccount, withdrawer } = await delegatedStakeAccount({ + provider, + lamports: LAMPORTS_PER_SOL * 2, + voteAccountToDelegate: bond.account.validatorVoteAccount, + lockup: { + custodian: Keypair.generate().publicKey, + epoch: nextEpoch + 1, + unixTimestamp: 0, + }, + }) + + const { instruction } = await fundBondInstruction({ + program, + configAccount: config.publicKey, + bondAccount: bond.publicKey, + stakeAccount, + authority: withdrawer, + }) + + warpToEpoch(provider, nextEpoch) + try { + await provider.sendIx([withdrawer], instruction) + throw new Error('failure expected as should be locked') + } catch (e) { + checkAnchorErrorMessage(e, 6028, 'stake account is locked-up') + } + }) + + it('fund bond', async () => { + const { stakeAccount, withdrawer } = await delegatedStakeAccount({ + provider, + lamports: LAMPORTS_PER_SOL * 2, + voteAccountToDelegate: bond.account.validatorVoteAccount, + }) + const [bondWithdrawer] = withdrawerAuthority( + config.publicKey, + program.programId + ) + + const [stakeAccountData] = await getAndCheckStakeAccount( + provider, + stakeAccount, + StakeStates.Delegated + ) + expect(stakeAccountData.Stake?.meta.authorized.withdrawer).not.toEqual( + bondWithdrawer + ) + expect(stakeAccountData.Stake?.meta.authorized.staker).not.toEqual( + bondWithdrawer + ) + + const { instruction } = await fundBondInstruction({ + program, + configAccount: config.publicKey, + bondAccount: bond.publicKey, + stakeAccount, + authority: withdrawer, + }) + const nextEpoch = + Number((await provider.context.banksClient.getClock()).epoch) + 1 + warpToEpoch(provider, nextEpoch) + await provider.sendIx([withdrawer], instruction) + + const [stakeAccountData2, stakeAccountInfo] = await getAndCheckStakeAccount( + provider, + stakeAccount, + StakeStates.Delegated + ) + expect(stakeAccountInfo.lamports).toEqual(LAMPORTS_PER_SOL * 2) + expect(stakeAccountData2.Stake?.meta.authorized.staker).toEqual( + bondWithdrawer + ) + expect(stakeAccountData2.Stake?.meta.authorized.withdrawer).toEqual( + bondWithdrawer + ) + expect(stakeAccountData2.Stake?.meta.lockup).toEqual({ + custodian: PublicKey.default, + epoch: new BN(0), + unixTimestamp: new BN(0), + }) + }) +}) diff --git a/packages/validator-bonds-sdk/__tests__/bankrun/initBond.spec.ts b/packages/validator-bonds-sdk/__tests__/bankrun/initBond.spec.ts new file mode 100644 index 00000000..c8b8b1e8 --- /dev/null +++ b/packages/validator-bonds-sdk/__tests__/bankrun/initBond.spec.ts @@ -0,0 +1,90 @@ +import { + Config, + ValidatorBondsProgram, + bondAddress, + getBond, + getConfig, + initBondInstruction, +} from '../../src' +import { BankrunExtendedProvider, initBankrunTest } from './bankrun' +import { + createUserAndFund, + executeInitConfigInstruction, +} from '../utils/testTransactions' +import { ProgramAccount } from '@coral-xyz/anchor' +import { Keypair, LAMPORTS_PER_SOL } from '@solana/web3.js' +import { createVoteAccount } from '../utils/staking' + +describe('Validator Bonds init bond account', () => { + let provider: BankrunExtendedProvider + let program: ValidatorBondsProgram + let config: ProgramAccount + + beforeAll(async () => { + ;({ provider, program } = await initBankrunTest()) + }) + + beforeEach(async () => { + const { configAccount } = await executeInitConfigInstruction({ + program, + provider, + epochsToClaimSettlement: 1, + withdrawLockupEpochs: 2, + }) + config = { + publicKey: configAccount, + account: await getConfig(program, configAccount), + } + expect(config.account.epochsToClaimSettlement).toEqual(1) + expect(config.account.withdrawLockupEpochs).toEqual(2) + }) + + it('init bond', async () => { + const bondAuthority = Keypair.generate() + const { voteAccount, authorizedWithdrawer } = await createVoteAccount( + provider + ) + const rentWallet = await createUserAndFund( + provider, + Keypair.generate(), + LAMPORTS_PER_SOL + ) + const { instruction, bondAccount } = await initBondInstruction({ + program, + configAccount: config.publicKey, + bondAuthority: bondAuthority.publicKey, + revenueShareHundredthBps: 30, + validatorVoteAccount: voteAccount, + validatorVoteWithdrawer: authorizedWithdrawer.publicKey, + rentPayer: rentWallet.publicKey, + }) + await provider.sendIx([rentWallet, authorizedWithdrawer], instruction) + + const rentWalletInfo = await provider.connection.getAccountInfo( + rentWallet.publicKey + ) + const bondAccountInfo = await provider.connection.getAccountInfo( + bondAccount + ) + if (bondAccountInfo === null) { + throw new Error(`Bond account ${bondAccountInfo} not found`) + } + const rentExempt = + await provider.connection.getMinimumBalanceForRentExemption( + bondAccountInfo.data.length + ) + expect(rentWalletInfo!.lamports).toEqual(LAMPORTS_PER_SOL - rentExempt) + console.log( + `Bond record data length ${bondAccountInfo.data.length}, exempt rent: ${rentExempt}` + ) + + const bondData = await getBond(program, bondAccount) + expect(bondData.authority).toEqual(bondAuthority.publicKey) + expect(bondData.bump).toEqual( + bondAddress(config.publicKey, voteAccount, program.programId)[1] + ) + expect(bondData.config).toEqual(config.publicKey) + expect(bondData.revenueShare).toEqual({ hundredthBps: 30 }) + expect(bondData.validatorVoteAccount).toEqual(voteAccount) + }) +}) diff --git a/packages/validator-bonds-sdk/__tests__/bankrun/initConfig.spec.ts b/packages/validator-bonds-sdk/__tests__/bankrun/initConfig.spec.ts index 4b2913d5..d24a2757 100644 --- a/packages/validator-bonds-sdk/__tests__/bankrun/initConfig.spec.ts +++ b/packages/validator-bonds-sdk/__tests__/bankrun/initConfig.spec.ts @@ -1,48 +1,27 @@ -import { Keypair } from '@solana/web3.js' -import { - ValidatorBondsProgram, - getConfig, - initConfigInstruction, -} from '../../src' -import { BankrunProvider } from 'anchor-bankrun' -import { - bankrunExecute, - bankrunTransaction, - initBankrunTest, -} from './utils/bankrun' +import { ValidatorBondsProgram, getConfig } from '../../src' +import { BankrunExtendedProvider, initBankrunTest } from './bankrun' +import { executeInitConfigInstruction } from '../utils/testTransactions' describe('Validator Bonds config account tests', () => { - let provider: BankrunProvider + let provider: BankrunExtendedProvider let program: ValidatorBondsProgram beforeAll(async () => { - // eslint-disable-next-line @typescript-eslint/no-extra-semi ;({ provider, program } = await initBankrunTest()) }) it('init config', async () => { - const adminAuthority = Keypair.generate().publicKey - const operatorAuthority = Keypair.generate().publicKey - expect(adminAuthority).not.toEqual(operatorAuthority) + const { configAccount, adminAuthority, operatorAuthority } = + await executeInitConfigInstruction({ + program, + provider, + epochsToClaimSettlement: 1, + withdrawLockupEpochs: 2, + }) - const tx = await bankrunTransaction(provider) - - const { keypair, instruction } = await initConfigInstruction({ - program, - adminAuthority, - operatorAuthority, - epochsToClaimSettlement: 1, - withdrawLockupEpochs: 2, - }) - tx.add(instruction) - await bankrunExecute(provider, tx, [provider.wallet, keypair!]) - - // Ensure the account was created - const configAccountAddress = keypair!.publicKey - const configData = await getConfig(program, configAccountAddress) - - expect(configData.adminAuthority).toEqual(adminAuthority) - expect(configData.operatorAuthority).toEqual(operatorAuthority) + const configData = await getConfig(program, configAccount) + expect(configData.adminAuthority).toEqual(adminAuthority.publicKey) + expect(configData.operatorAuthority).toEqual(operatorAuthority.publicKey) expect(configData.epochsToClaimSettlement).toEqual(1) expect(configData.withdrawLockupEpochs).toEqual(2) }) diff --git a/packages/validator-bonds-sdk/__tests__/bankrun/merge.spec.ts b/packages/validator-bonds-sdk/__tests__/bankrun/merge.spec.ts new file mode 100644 index 00000000..73abbeb8 --- /dev/null +++ b/packages/validator-bonds-sdk/__tests__/bankrun/merge.spec.ts @@ -0,0 +1,458 @@ +import { + Config, + ValidatorBondsProgram, + bondAddress, + getConfig, + mergeInstruction, + settlementAddress, + settlementAuthority, + withdrawerAuthority, +} from '../../src' +import { + BankrunExtendedProvider, + assertNotExist, + initBankrunTest, + warpToEpoch, +} from './bankrun' +import { + executeInitConfigInstruction, + executeWithdraw, +} from '../utils/testTransactions' +import { ProgramAccount } from '@coral-xyz/anchor' +import { + LAMPORTS_PER_SOL, + PublicKey, + SYSVAR_STAKE_HISTORY_PUBKEY, + StakeProgram, +} from '@solana/web3.js' +import { + authorizeStakeAccount, + createVoteAccount, + delegatedStakeAccount, + initializedStakeAccount, +} from '../utils/staking' +import { checkAnchorErrorMessage, pubkey } from '../utils/helpers' + +// ------------------- +/// TODO: +// - missing handling for settlement authority +// - add a test for lockup checks +// ------------------ + +describe('Validator Bonds fund bond account', () => { + let provider: BankrunExtendedProvider + let program: ValidatorBondsProgram + let config: ProgramAccount + const startUpEpoch = Math.floor(Math.random() * 100) + 100 + + beforeAll(async () => { + ;({ provider, program } = await initBankrunTest()) + warpToEpoch(provider, startUpEpoch) + }) + + beforeEach(async () => { + const { configAccount } = await executeInitConfigInstruction({ + program, + provider, + }) + config = { + publicKey: configAccount, + account: await getConfig(program, configAccount), + } + }) + + it('cannot merge with staker authority not belonging to bonds', async () => { + const { stakeAccount: nonDelegatedStakeAccount, staker } = + await initializedStakeAccount(provider) + const { stakeAccount: nonDelegatedStakeAccount2 } = + await initializedStakeAccount(provider, undefined, undefined, staker) + const instruction = await program.methods + .merge({ + settlement: PublicKey.default, + }) + .accounts({ + config: config.publicKey, + sourceStake: nonDelegatedStakeAccount2, + destinationStake: nonDelegatedStakeAccount, + stakerAuthority: pubkey(staker), + stakeHistory: SYSVAR_STAKE_HISTORY_PUBKEY, + stakeProgram: StakeProgram.programId, + }) + .instruction() + try { + await provider.sendIx([], instruction) + throw new Error( + 'failure expected as accounts are not owned by bonds program' + ) + } catch (e) { + checkAnchorErrorMessage(e, 6043, 'does not belong to bonds program') + } + }) + + it('cannot merge with wrong withdrawer authorities not belonging to bonds', async () => { + const [bondWithdrawer] = withdrawerAuthority( + config.publicKey, + program.programId + ) + const { stakeAccount: nonDelegatedStakeAccount } = + await initializedStakeAccount( + provider, + undefined, + undefined, + bondWithdrawer + ) + const { stakeAccount: nonDelegatedStakeAccount2 } = + await initializedStakeAccount( + provider, + undefined, + undefined, + bondWithdrawer + ) + const { instruction } = await mergeInstruction({ + program, + configAccount: config.publicKey, + sourceStakeAccount: nonDelegatedStakeAccount2, + destinationStakeAccount: nonDelegatedStakeAccount, + }) + try { + await provider.sendIx([], instruction) + throw new Error( + 'failure expected as accounts are not owned by bonds program' + ) + } catch (e) { + checkAnchorErrorMessage(e, 6043, 'does not belong to bonds program') + } + }) + + it('cannot merge with non delegated stake state', async () => { + const [bondWithdrawer] = withdrawerAuthority( + config.publicKey, + program.programId + ) + const { stakeAccount: nonDelegatedStakeAccount } = + await initializedStakeAccount( + provider, + undefined, + undefined, + bondWithdrawer, + bondWithdrawer + ) + const { stakeAccount: nonDelegatedStakeAccount2 } = + await initializedStakeAccount( + provider, + undefined, + undefined, + bondWithdrawer, + bondWithdrawer + ) + const { instruction } = await mergeInstruction({ + program, + configAccount: config.publicKey, + sourceStakeAccount: nonDelegatedStakeAccount, + destinationStakeAccount: nonDelegatedStakeAccount2, + }) + try { + await provider.sendIx([], instruction) + throw new Error('failure expected; non delegated') + } catch (e) { + checkAnchorErrorMessage( + e, + 6045, + 'Delegation of provided stake account mismatches' + ) + } + }) + + it('cannot merge different delegation', async () => { + const [bondWithdrawer] = withdrawerAuthority( + config.publicKey, + program.programId + ) + const { stakeAccount: stakeAccount1, withdrawer: withdrawer1 } = + await delegatedStakeAccount({ + provider, + lamports: 6 * LAMPORTS_PER_SOL, + lockup: undefined, + }) + await authorizeStakeAccount({ + provider, + authority: withdrawer1, + stakeAccount: stakeAccount1, + staker: bondWithdrawer, + withdrawer: bondWithdrawer, + }) + + const { stakeAccount: stakeAccount2, withdrawer: withdrawer2 } = + await delegatedStakeAccount({ + provider, + lamports: 3 * LAMPORTS_PER_SOL, + lockup: undefined, + }) + await authorizeStakeAccount({ + provider, + authority: withdrawer2, + stakeAccount: stakeAccount2, + staker: bondWithdrawer, + withdrawer: bondWithdrawer, + }) + + const { instruction } = await mergeInstruction({ + program, + configAccount: config.publicKey, + sourceStakeAccount: stakeAccount2, + destinationStakeAccount: stakeAccount1, + }) + try { + await provider.sendIx([], instruction) + throw new Error('failure expected; wrong delegation') + } catch (e) { + checkAnchorErrorMessage( + e, + 6045, + 'Delegation of provided stake account mismatches' + ) + } + const { instruction: instruction2 } = await mergeInstruction({ + program, + configAccount: config.publicKey, + sourceStakeAccount: stakeAccount1, + destinationStakeAccount: stakeAccount2, + }) + try { + await provider.sendIx([], instruction2) + throw new Error('failure expected; wrong delegation') + } catch (e) { + checkAnchorErrorMessage( + e, + 6045, + 'Delegation of provided stake account mismatches' + ) + } + }) + + it('cannot merge different deactivated delegation', async () => { + const [bondWithdrawer] = withdrawerAuthority( + config.publicKey, + program.programId + ) + const { + stakeAccount: stakeAccount1, + withdrawer: withdrawer1, + staker: staker1, + } = await delegatedStakeAccount({ + provider, + lamports: 6 * LAMPORTS_PER_SOL, + lockup: undefined, + }) + + const { + stakeAccount: stakeAccount2, + withdrawer: withdrawer2, + staker: staker2, + } = await delegatedStakeAccount({ + provider, + lamports: 3 * LAMPORTS_PER_SOL, + lockup: undefined, + }) + + // warp to make the funds effective in stake account + warpToEpoch( + provider, + Number((await provider.context.banksClient.getClock()).epoch) + 1 + ) + const deactivate1Ix = StakeProgram.deactivate({ + stakePubkey: stakeAccount1, + authorizedPubkey: staker1.publicKey, + }) + const deactivate2Ix = StakeProgram.deactivate({ + stakePubkey: stakeAccount2, + authorizedPubkey: staker2.publicKey, + }) + await provider.sendIx([staker1, staker2], deactivate1Ix, deactivate2Ix) + // deactivated but funds are still effective, withdraw cannot work + try { + executeWithdraw(provider, stakeAccount1, withdrawer1, undefined, 1) + } catch (e) { + if ( + !(e as Error).message.includes('insufficient funds for instruction') + ) { + throw e + } + } + // making funds ineffective, withdraw works + warpToEpoch( + provider, + Number((await provider.context.banksClient.getClock()).epoch) + 1 + ) + executeWithdraw(provider, stakeAccount1, withdrawer1, undefined, 1) + + await authorizeStakeAccount({ + provider, + authority: withdrawer1, + stakeAccount: stakeAccount1, + staker: bondWithdrawer, + withdrawer: bondWithdrawer, + }) + await authorizeStakeAccount({ + provider, + authority: withdrawer2, + stakeAccount: stakeAccount2, + staker: bondWithdrawer, + withdrawer: bondWithdrawer, + }) + + const { instruction } = await mergeInstruction({ + program, + configAccount: config.publicKey, + sourceStakeAccount: stakeAccount2, + destinationStakeAccount: stakeAccount1, + }) + try { + await provider.sendIx([], instruction) + throw new Error('failure expected; wrong delegation') + } catch (e) { + checkAnchorErrorMessage( + e, + 6045, + 'Delegation of provided stake account mismatches' + ) + } + const { instruction: instruction2 } = await mergeInstruction({ + program, + configAccount: config.publicKey, + sourceStakeAccount: stakeAccount1, + destinationStakeAccount: stakeAccount2, + }) + try { + await provider.sendIx([], instruction2) + throw new Error('failure expected; wrong delegation') + } catch (e) { + checkAnchorErrorMessage( + e, + 6045, + 'Delegation of provided stake account mismatches' + ) + } + }) + + it('cannot merge settlement and bond authority', async () => { + const voteAccount = (await createVoteAccount(provider)).voteAccount + const [bondWithdrawer] = withdrawerAuthority( + config.publicKey, + program.programId + ) + const [bond] = bondAddress(config.publicKey, program.programId) + const [settlement] = settlementAddress( + bond, + Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]), + program.programId + ) + const [settlementStaker] = settlementAuthority( + settlement, + program.programId + ) + const { stakeAccount: stakeAccount1, withdrawer: withdrawer1 } = + await delegatedStakeAccount({ + provider, + lamports: 6 * LAMPORTS_PER_SOL, + lockup: undefined, + voteAccountToDelegate: voteAccount, + }) + await authorizeStakeAccount({ + provider, + authority: withdrawer1, + stakeAccount: stakeAccount1, + withdrawer: bondWithdrawer, + staker: bondWithdrawer, + }) + + const { stakeAccount: stakeAccount2, withdrawer: withdrawer2 } = + await delegatedStakeAccount({ + provider, + lamports: 3 * LAMPORTS_PER_SOL, + lockup: undefined, + voteAccountToDelegate: voteAccount, + }) + await authorizeStakeAccount({ + provider, + authority: withdrawer2, + stakeAccount: stakeAccount2, + withdrawer: bondWithdrawer, + staker: settlementStaker, + }) + + const { instruction } = await mergeInstruction({ + program, + configAccount: config.publicKey, + sourceStakeAccount: stakeAccount2, + destinationStakeAccount: stakeAccount1, + }) + try { + await provider.sendIx([], instruction) + throw new Error('failure expected; wrong authorities') + } catch (e) { + checkAnchorErrorMessage(e, 6042, 'staker does not match') + } + const { instruction: instruction2 } = await mergeInstruction({ + program, + configAccount: config.publicKey, + sourceStakeAccount: stakeAccount1, + destinationStakeAccount: stakeAccount2, + }) + try { + await provider.sendIx([], instruction2) + throw new Error('failure expected; wrong authorities') + } catch (e) { + checkAnchorErrorMessage(e, 6042, 'staker does not match') + } + }) + + it('merging', async () => { + const [bondWithdrawer] = withdrawerAuthority( + config.publicKey, + program.programId + ) + const { + stakeAccount: stakeAccount1, + withdrawer: withdrawer1, + voteAccount, + } = await delegatedStakeAccount({ + provider, + lamports: 6 * LAMPORTS_PER_SOL, + lockup: undefined, + }) + await authorizeStakeAccount({ + provider, + authority: withdrawer1, + stakeAccount: stakeAccount1, + staker: bondWithdrawer, + withdrawer: bondWithdrawer, + }) + + const { stakeAccount: stakeAccount2, withdrawer: withdrawer2 } = + await delegatedStakeAccount({ + provider, + lamports: 3 * LAMPORTS_PER_SOL, + lockup: undefined, + voteAccountToDelegate: voteAccount, + }) + await authorizeStakeAccount({ + provider, + authority: withdrawer2, + stakeAccount: stakeAccount2, + staker: bondWithdrawer, + withdrawer: bondWithdrawer, + }) + const { instruction } = await mergeInstruction({ + program, + configAccount: config.publicKey, + sourceStakeAccount: stakeAccount2, + destinationStakeAccount: stakeAccount1, + }) + await provider.sendIx([], instruction) + + await assertNotExist(provider, stakeAccount2) + const stakeAccount = await provider.connection.getAccountInfo(stakeAccount1) + expect(stakeAccount?.lamports).toEqual(9 * LAMPORTS_PER_SOL) + }) +}) diff --git a/packages/validator-bonds-sdk/__tests__/bankrun/solanaStake.spec.ts b/packages/validator-bonds-sdk/__tests__/bankrun/solanaStake.spec.ts index 99e69654..06424158 100644 --- a/packages/validator-bonds-sdk/__tests__/bankrun/solanaStake.spec.ts +++ b/packages/validator-bonds-sdk/__tests__/bankrun/solanaStake.spec.ts @@ -1,31 +1,923 @@ -// import { Keypair } from '@solana/web3.js' -// import { -// ValidatorBondsProgram, -// getConfig, -// initConfigInstruction, -// } from '../../src' -// import { BankrunProvider } from 'anchor-bankrun' -// import { -// bankrunExecute, -// bankrunTransaction, -// initBankrunTest, -// } from './utils/bankrun' +import { + Authorized, + Keypair, + Lockup, + PublicKey, + StakeAuthorizationLayout, + SystemProgram, + LAMPORTS_PER_SOL, +} from '@solana/web3.js' +import { Clock } from 'solana-bankrun' +import { + BankrunExtendedProvider, + assertNotExist, + bankrunExecuteIx, + initBankrunTest, + warpToEpoch, +} from './bankrun' +import { StakeProgram } from '@solana/web3.js' +import { + StakeStates, + createVoteAccount, + delegatedStakeAccount, + getAndCheckStakeAccount, + getRentExemptStake, + getRentExemptVote, + initializedStakeAccount, + nonInitializedStakeAccount, + setLockup, +} from '../utils/staking' +import { verifyErrorMessage } from '../utils/helpers' describe('Solana stake account behavior verification', () => { - // let provider: BankrunProvider - // let program: ValidatorBondsProgram + let provider: BankrunExtendedProvider + let rentExemptStake: number + let rentExemptVote: number + const startUpEpoch = 42 beforeAll(async () => { - // eslint-disable-next-line @typescript-eslint/no-extra-semi - // ;({ provider, program } = await initBankrunTest()) + ;({ provider } = await initBankrunTest()) + rentExemptStake = await getRentExemptStake(provider) + rentExemptVote = await getRentExemptVote(provider) + warpToEpoch(provider, startUpEpoch) }) - // TODO: #1 when stake account is created with lockup what happens when authority is changed? - // will the lockup custodian stays the same as before? - // can be lockup removed completely? - // what the 'custodian' field on 'authorize' method has the significance for? - // - // TODO: #2 check what happens when lockup account is merged with non-lockup account? - // TODO: #3 what happen after split of stake account with authorities, are they maintained as in the original one? - it('', async () => {}) + it('cannot merge uninitialized + merge initialized with correct meta', async () => { + const [sourcePubkey] = await nonInitializedStakeAccount( + provider, + rentExemptStake + ) + const [destPubkey] = await nonInitializedStakeAccount( + provider, + rentExemptStake + ) + + await getAndCheckStakeAccount( + provider, + sourcePubkey, + StakeStates.Uninitialized + ) + await getAndCheckStakeAccount( + provider, + destPubkey, + StakeStates.Uninitialized + ) + const mergeUninitializedTx = StakeProgram.merge({ + stakePubkey: destPubkey, + sourceStakePubKey: sourcePubkey, + authorizedPubkey: provider.wallet.publicKey, + }) + // 1. CANNOT MERGE WHEN UNINITIALIZED + await verifyErrorMessage( + provider, + '1.', + 'invalid account data for instruction', + [provider.wallet], + mergeUninitializedTx + ) + + const sourceStaker = Keypair.generate() + const sourceWithdrawer = Keypair.generate() + const destStaker = Keypair.generate() + const destWithdrawer = Keypair.generate() + const sourceInitIx = StakeProgram.initialize({ + stakePubkey: sourcePubkey, + authorized: new Authorized( + sourceStaker.publicKey, + sourceWithdrawer.publicKey + ), + lockup: undefined, + }) + const destInitIx = StakeProgram.initialize({ + stakePubkey: destPubkey, + authorized: new Authorized( + destStaker.publicKey, + destWithdrawer.publicKey + ), + lockup: undefined, + }) + await bankrunExecuteIx( + provider, + [provider.wallet], + sourceInitIx, + destInitIx + ) + + await getAndCheckStakeAccount( + provider, + sourcePubkey, + StakeStates.Initialized + ) + await getAndCheckStakeAccount(provider, destPubkey, StakeStates.Initialized) + + const mergeInitializedWrongAuthorityTx = StakeProgram.merge({ + stakePubkey: destPubkey, + sourceStakePubKey: sourcePubkey, + authorizedPubkey: sourceStaker.publicKey, + }) + // 2. CANNOT MERGE WHEN HAVING DIFFERENT STAKER AUTHORITIES + await verifyErrorMessage( + provider, + '2.', + 'missing required signature for instruction', + [provider.wallet, sourceStaker], + mergeInitializedWrongAuthorityTx + ) + + // staker authority change is ok to be signed by staker + const changeStakerAuthIx = StakeProgram.authorize({ + stakePubkey: destPubkey, + authorizedPubkey: destStaker.publicKey, + newAuthorizedPubkey: sourceStaker.publicKey, + stakeAuthorizationType: StakeAuthorizationLayout.Staker, + custodianPubkey: undefined, + }) + await bankrunExecuteIx( + provider, + [provider.wallet, destStaker], + changeStakerAuthIx + ) + + // pushing clock forward to get new latest blockhash from the client + provider.context.warpToSlot( + (await provider.context.banksClient.getClock()).slot + BigInt(1) + ) + + const mergeInitializedWrongWithdrawAuthorityTx = StakeProgram.merge({ + stakePubkey: destPubkey, + sourceStakePubKey: sourcePubkey, + authorizedPubkey: sourceStaker.publicKey, + }) + // 3. CANNOT MERGE WHEN HAVING DIFFERENT WITHDRAWER AUTHORITIES + // https://github.com/solana-labs/solana/blob/v1.17.7/programs/stake/src/stake_state.rs#L1392 + await verifyErrorMessage( + provider, + '3.', + 'custom program error: 0x6', + [provider.wallet, sourceStaker], + mergeInitializedWrongWithdrawAuthorityTx + ) + + const changeWithdrawerAuthIx = StakeProgram.authorize({ + stakePubkey: destPubkey, + authorizedPubkey: destWithdrawer.publicKey, + newAuthorizedPubkey: sourceWithdrawer.publicKey, + stakeAuthorizationType: StakeAuthorizationLayout.Withdrawer, + custodianPubkey: undefined, + }) + await bankrunExecuteIx( + provider, + [provider.wallet, destWithdrawer], + changeWithdrawerAuthIx + ) + + // pushing clock forward to get new latest blockhash from the client + provider.context.warpToSlot( + (await provider.context.banksClient.getClock()).slot + BigInt(1) + ) + + // 4. FINAL SUCCESSFUL MERGE + const mergeTx = StakeProgram.merge({ + stakePubkey: destPubkey, + sourceStakePubKey: sourcePubkey, + authorizedPubkey: sourceStaker.publicKey, + }) + await bankrunExecuteIx(provider, [provider.wallet, sourceStaker], mergeTx) + }) + + /** + * Can be lockup removed completely? + * - no, it seems the only way to change the lockup is to run SetLockup that configures but not removes it + * - when lockup is active the only way to change it is to use the custodian signature + * - when lockup is not active the only way to change it is to use the withdrawer signature + * + * When calling authorize with custodianPubkey, the lockup is not changed + * - when lockup is active, the custodian signature is required, custodianPubkey is a way to pass the lockup custodian to ix + */ + it('merging stake account with different lockup metadata', async () => { + const { epoch } = await provider.context.banksClient.getClock() + const staker = Keypair.generate() + const withdrawer = Keypair.generate() + const stakeAccount1Epoch = Number(epoch) + 20 + const { stakeAccount: stakeAccount1 } = await initializedStakeAccount( + provider, + new Lockup(0, stakeAccount1Epoch, PublicKey.default), + rentExemptStake, + staker, + withdrawer + ) + const custodian2 = Keypair.generate() + const { stakeAccount: stakeAccount2 } = await initializedStakeAccount( + provider, + new Lockup(0, -1, custodian2.publicKey), // max possible epoch lockup + rentExemptStake, + staker, + withdrawer + ) + const mergeTx = StakeProgram.merge({ + stakePubkey: stakeAccount2, + sourceStakePubKey: stakeAccount1, + authorizedPubkey: staker.publicKey, + }) + console.log( + '1. CANNOT MERGE when active LOCKUP when meta data is different' + ) + await verifyErrorMessage( + provider, + '1.', + 'custom program error: 0x6', + [provider.wallet, staker], + mergeTx + ) + + // we can change lockup data to match with custodian + const setLockupIx = setLockup({ + stakePubkey: stakeAccount2, + authorizedPubkey: custodian2.publicKey, + epoch: stakeAccount1Epoch, + }) + await bankrunExecuteIx(provider, [provider.wallet, custodian2], setLockupIx) + + provider.context.warpToSlot( + (await provider.context.banksClient.getClock()).slot + BigInt(1) + ) + const mergeTx2 = StakeProgram.merge({ + stakePubkey: stakeAccount2, + sourceStakePubKey: stakeAccount1, + authorizedPubkey: staker.publicKey, + }) + console.log( + '2. CANNOT MERGE EVEN WHEN active LOCKUP WHEN Lockup custodians are different' + ) + await verifyErrorMessage( + provider, + '2.', + 'custom program error: 0x6', // MergeMismatch + [provider.wallet, staker], + mergeTx2 + ) + + // we can change lockup data to match the stake account 1 + const setLockupIx2 = setLockup({ + stakePubkey: stakeAccount2, + authorizedPubkey: custodian2.publicKey, + custodian: PublicKey.default, + }) + await bankrunExecuteIx( + provider, + [provider.wallet, custodian2], + setLockupIx2 + ) + + provider.context.warpToSlot( + (await provider.context.banksClient.getClock()).slot + BigInt(1) + ) + + // merging stakeAccount1 --> stakeAccount2 + const mergeTx3 = StakeProgram.merge({ + stakePubkey: stakeAccount2, + sourceStakePubKey: stakeAccount1, + authorizedPubkey: staker.publicKey, + }) + console.log( + '3. for active LOCKUP MERGING with the same LOCKUP metadata is permitted' + ) + await bankrunExecuteIx(provider, [provider.wallet, staker], mergeTx3) + // merged, stakeAccount1 is gone + await assertNotExist(provider, stakeAccount1) + + console.log( + '4. AUTHORIZE to new staker, lockup is over, not necessary to use custodian' + ) + let [stakeAccount2Data] = await getAndCheckStakeAccount( + provider, + stakeAccount2, + StakeStates.Initialized + ) + expect(stakeAccount2Data.Initialized?.meta.authorized.staker).toEqual( + staker.publicKey + ) + const newStaker = Keypair.generate() + const changeStakerAuthIx = StakeProgram.authorize({ + stakePubkey: stakeAccount2, + authorizedPubkey: staker.publicKey, + newAuthorizedPubkey: newStaker.publicKey, + stakeAuthorizationType: StakeAuthorizationLayout.Staker, + // using random non-existent custodian here + custodianPubkey: Keypair.generate().publicKey, + }) + await bankrunExecuteIx( + provider, + [provider.wallet, staker], + changeStakerAuthIx + ) + ;[stakeAccount2Data] = await getAndCheckStakeAccount( + provider, + stakeAccount2, + StakeStates.Initialized + ) + expect(stakeAccount2Data.Initialized?.meta.authorized.staker).toEqual( + newStaker.publicKey + ) + + console.log( + '5. MERGE of inactive LOCKUP to active lockup is not possible without custodian' + ) + const { stakeAccount: stakeAccountInactive } = + await initializedStakeAccount( + provider, + new Lockup(0, 0, PublicKey.default), + rentExemptStake, + staker, + withdrawer + ) + // merging stakeAccountInactive -> stakeAccount2 + const mergeTxInactive = StakeProgram.merge({ + stakePubkey: stakeAccount2, + sourceStakePubKey: stakeAccountInactive, + authorizedPubkey: staker.publicKey, + }) + await verifyErrorMessage( + provider, + '5.', + 'missing required signature for instruction', + [provider.wallet, staker], + mergeTxInactive + ) + }) + + it('merge stake account with running lockup', async () => { + const clock = await provider.context.banksClient.getClock() + const staker = Keypair.generate() + const withdrawer = Keypair.generate() + const custodianWallet = provider.wallet + const unixTimestampLockup = Number(clock.unixTimestamp) + 1000 + const lockup = new Lockup(unixTimestampLockup, 0, custodianWallet.publicKey) + const { stakeAccount: stakeAccount1 } = await initializedStakeAccount( + provider, + lockup, + rentExemptStake, + staker, + withdrawer + ) + const { stakeAccount: stakeAccount2 } = await initializedStakeAccount( + provider, + lockup, + rentExemptStake, + staker, + withdrawer + ) + + console.log('1. AUTHORIZE STAKER is possible when lockup is running') + const newStaker = Keypair.generate() + const changeStakerAuthIx = StakeProgram.authorize({ + stakePubkey: stakeAccount1, + authorizedPubkey: staker.publicKey, + newAuthorizedPubkey: newStaker.publicKey, + stakeAuthorizationType: StakeAuthorizationLayout.Staker, + }) + const changeStakerAuthIx2 = StakeProgram.authorize({ + stakePubkey: stakeAccount2, + authorizedPubkey: staker.publicKey, + newAuthorizedPubkey: newStaker.publicKey, + stakeAuthorizationType: StakeAuthorizationLayout.Staker, + }) + await bankrunExecuteIx( + provider, + [provider.wallet, staker], + changeStakerAuthIx, + changeStakerAuthIx2 + ) + + console.log( + '2. AUTHORIZE WITHDRAWER with LOCKUP being active only possible with custodian signature' + ) + const newWithdrawer = Keypair.generate() + const changeWithdrawerNoCustodianIx = StakeProgram.authorize({ + stakePubkey: stakeAccount1, + authorizedPubkey: withdrawer.publicKey, + newAuthorizedPubkey: newWithdrawer.publicKey, + stakeAuthorizationType: StakeAuthorizationLayout.Withdrawer, + custodianPubkey: undefined, + }) + await verifyErrorMessage( + provider, + '2.', + 'custom program error: 0x7', // CustodianMissing + [provider.wallet, withdrawer], + changeWithdrawerNoCustodianIx + ) + const changeWithdrawer1Ix = StakeProgram.authorize({ + stakePubkey: stakeAccount1, + authorizedPubkey: withdrawer.publicKey, + newAuthorizedPubkey: newWithdrawer.publicKey, + stakeAuthorizationType: StakeAuthorizationLayout.Withdrawer, + custodianPubkey: custodianWallet.publicKey, + }) + const changeWithdrawer2Ix = StakeProgram.authorize({ + stakePubkey: stakeAccount2, + authorizedPubkey: withdrawer.publicKey, + newAuthorizedPubkey: newWithdrawer.publicKey, + stakeAuthorizationType: StakeAuthorizationLayout.Withdrawer, + custodianPubkey: custodianWallet.publicKey, + }) + await bankrunExecuteIx( + provider, + [provider.wallet, withdrawer, custodianWallet], + changeWithdrawer1Ix, + changeWithdrawer2Ix + ) + + // stakeAccount2 --> merged to --> stakeAccount1 + const mergeTx = StakeProgram.merge({ + stakePubkey: stakeAccount1, + sourceStakePubKey: stakeAccount2, + authorizedPubkey: newStaker.publicKey, + }) + await bankrunExecuteIx(provider, [provider.wallet, newStaker], mergeTx) + await assertNotExist(provider, stakeAccount2) + await getAndCheckStakeAccount( + provider, + stakeAccount1, + StakeStates.Initialized + ) + + // transferring some SOLs to have enough for delegation + const transferIx = SystemProgram.transfer({ + fromPubkey: provider.wallet.publicKey, + toPubkey: stakeAccount1, + lamports: LAMPORTS_PER_SOL * 10, + }) + await bankrunExecuteIx(provider, [provider.wallet], transferIx) + + // creating vote account to delegate to it + const { voteAccount } = await createVoteAccount(provider, rentExemptVote) + const delegateIx = StakeProgram.delegate({ + stakePubkey: stakeAccount1, + authorizedPubkey: newStaker.publicKey, + votePubkey: voteAccount, + }) + await bankrunExecuteIx(provider, [provider.wallet, newStaker], delegateIx) + await getAndCheckStakeAccount( + provider, + stakeAccount1, + StakeStates.Delegated + ) + + const deactivateIx = StakeProgram.deactivate({ + stakePubkey: stakeAccount1, + authorizedPubkey: newStaker.publicKey, + }) + await bankrunExecuteIx(provider, [provider.wallet, newStaker], deactivateIx) + + console.log('3. CANNOT withdraw when lockup is active') + const withdrawIx = StakeProgram.withdraw({ + stakePubkey: stakeAccount1, + authorizedPubkey: newWithdrawer.publicKey, + toPubkey: provider.wallet.publicKey, + lamports: LAMPORTS_PER_SOL * 5, + }) + await verifyErrorMessage( + provider, + '3.', + 'custom program error: 0x1', // LockupInForce + [provider.wallet, newWithdrawer], + withdrawIx + ) + + console.log( + '4. WE CAN withdraw when withdrawer AND custodian sign when lockup is active' + ) + const withdrawIx2 = StakeProgram.withdraw({ + stakePubkey: stakeAccount1, + authorizedPubkey: newWithdrawer.publicKey, + toPubkey: provider.wallet.publicKey, + lamports: LAMPORTS_PER_SOL * 5, + custodianPubkey: custodianWallet.publicKey, + }) + await bankrunExecuteIx( + provider, + [custodianWallet, newWithdrawer], + withdrawIx2 + ) + + console.log('5. WE CAN withdraw when lockup is over') + // moving time forward to expire the lockup + provider.context.setClock( + new Clock( + clock.slot, + clock.epochStartTimestamp, + clock.epoch, + clock.leaderScheduleEpoch, + BigInt(unixTimestampLockup + 1) + ) + ) + const withdrawIx3 = StakeProgram.withdraw({ + stakePubkey: stakeAccount1, + authorizedPubkey: newWithdrawer.publicKey, + toPubkey: provider.wallet.publicKey, + lamports: LAMPORTS_PER_SOL, + }) + await bankrunExecuteIx( + provider, + [provider.wallet, newWithdrawer], + withdrawIx3 + ) + provider.context.warpToSlot( + (await provider.context.banksClient.getClock()).slot + BigInt(1) + ) + }) + + it('merge delegated stake account', async () => { + const clock = await provider.context.banksClient.getClock() + const custodian = provider.wallet + const lockup = new Lockup(0, -1, custodian.publicKey) // max lockup at the end of universe + const staker = Keypair.generate() + const withdrawer = Keypair.generate() + // what can happen when not funded enough: custom program error: 0xc => InsufficientDelegation + const stakeAccount1 = await delegatedStakeAccount({ + provider, + lockup, + lamports: LAMPORTS_PER_SOL * 12, + staker, + withdrawer, + }) + const stakeAccount2 = await delegatedStakeAccount({ + provider, + lockup, + lamports: LAMPORTS_PER_SOL * 13, + staker, + withdrawer, + }) + + console.log( + '1. CANNOT MERGE WHEN STAKED TO DIFFERENT VOTE ACCOUNTS (the same lockup metadata)' + ) + const mergeTx = StakeProgram.merge({ + stakePubkey: stakeAccount1.stakeAccount, + sourceStakePubKey: stakeAccount2.stakeAccount, + authorizedPubkey: staker.publicKey, + }) + await verifyErrorMessage( + provider, + '1.', + 'custom program error: 0x6', // MergeMismatch + [provider.wallet, staker], + mergeTx + ) + + console.log( + '2. MERGING WHEN STAKED TO THE SAME VOTE ACCOUNT (the same lockup meta data)' + ) + const delegateIx = StakeProgram.delegate({ + stakePubkey: stakeAccount2.stakeAccount, + authorizedPubkey: staker.publicKey, + votePubkey: stakeAccount1.voteAccount, + }) + await bankrunExecuteIx(provider, [provider.wallet, staker], delegateIx) + provider.context.warpToSlot(clock.slot + BigInt(1)) + await bankrunExecuteIx(provider, [provider.wallet, staker], mergeTx) + await assertNotExist(provider, stakeAccount2.stakeAccount) + + console.log('3. CANNOT MERGE DEACTIVATING (the same lockup meta data)') + const stakeAccount3 = await delegatedStakeAccount({ + provider, + lockup, + lamports: LAMPORTS_PER_SOL * 14, + staker, + withdrawer, + voteAccountToDelegate: stakeAccount1.voteAccount, + }) + let nextEpoch = + Number((await provider.context.banksClient.getClock()).epoch) + 1 + warpToEpoch(provider, nextEpoch) + const deactivateIx = StakeProgram.deactivate({ + stakePubkey: stakeAccount3.stakeAccount, + authorizedPubkey: staker.publicKey, + }) + await bankrunExecuteIx(provider, [provider.wallet, staker], deactivateIx) + let [stakeAccount3Data] = await getAndCheckStakeAccount( + provider, + stakeAccount3.stakeAccount, + StakeStates.Delegated + ) + expect( + stakeAccount3Data.Stake?.stake.delegation.deactivationEpoch.toNumber() + ).toEqual(nextEpoch) + const mergeTx3 = StakeProgram.merge({ + stakePubkey: stakeAccount1.stakeAccount, + sourceStakePubKey: stakeAccount3.stakeAccount, + authorizedPubkey: staker.publicKey, + }) + await verifyErrorMessage( + provider, + '3.', + 'custom program error: 0x5', // MergeTransientStake + [provider.wallet, staker], + mergeTx3 + ) + + console.log( + '4. CANNOT MERGE ON DIFFERENT STATE activated vs. deactivated (the same lockup meta data)' + ) + nextEpoch = + Number((await provider.context.banksClient.getClock()).epoch) + 1 + warpToEpoch(provider, nextEpoch) + await verifyErrorMessage( + provider, + '4.', + 'custom program error: 0x6', // MergeMismatch + [provider.wallet, staker], + mergeTx3 + ) + + console.log('5. stake the deactivated tokens once again') + const delegateIx3 = StakeProgram.delegate({ + stakePubkey: stakeAccount3.stakeAccount, + authorizedPubkey: staker.publicKey, + votePubkey: stakeAccount1.voteAccount, + }) + await bankrunExecuteIx(provider, [provider.wallet, staker], delegateIx3) + const currentEpoch = Number( + (await provider.context.banksClient.getClock()).epoch + ) + ;[stakeAccount3Data] = await getAndCheckStakeAccount( + provider, + stakeAccount3.stakeAccount, + StakeStates.Delegated + ) + expect( + stakeAccount3Data.Stake?.stake.delegation.deactivationEpoch.toString() + ).toEqual('18446744073709551615') // max u64 + expect( + stakeAccount3Data.Stake?.stake.delegation.activationEpoch.toString() + ).toEqual(currentEpoch.toString()) + + console.log('6. MERGING ACTIVATED stake (the same lockup meta data)') + warpToEpoch(provider, currentEpoch + 1) + await bankrunExecuteIx(provider, [provider.wallet, staker], mergeTx3) + await assertNotExist(provider, stakeAccount3.stakeAccount) + await getAndCheckStakeAccount( + provider, + stakeAccount1.stakeAccount, + StakeStates.Delegated + ) + const stakeAccountInfo = await provider.connection.getAccountInfo( + stakeAccount1.stakeAccount + ) + expect(stakeAccountInfo?.lamports).toEqual( + LAMPORTS_PER_SOL * 12 + LAMPORTS_PER_SOL * 13 + LAMPORTS_PER_SOL * 14 + ) + }) + + /** + * What happened with merged lockup? + * - lockup metadata is the same as the first account where the second was merged into + * What happens when lockup account is merged with non-lockup account? + * - that's not possible, either lockup metadata matches or both are non-lockup + * May be two deactivated stake accounts delegated to different vote accounts merged together? + * - yes, they could be merged together + * + */ + it('merging non-locked delegated stake accounts', async () => { + const clock = await provider.context.banksClient.getClock() + const staker = Keypair.generate() + const lockedEpoch = 10 + const lockedTimestamp = 33 + const lockedCustodian = Keypair.generate().publicKey + const { + stakeAccount: stakeAccount1, + withdrawer, + voteAccount, + } = await delegatedStakeAccount({ + provider, + lockup: new Lockup(lockedTimestamp, lockedEpoch, lockedCustodian), + lamports: LAMPORTS_PER_SOL * 5, + staker, + }) + const { stakeAccount: stakeAccount2 } = await delegatedStakeAccount({ + provider, + voteAccountToDelegate: voteAccount, + lockup: new Lockup( + lockedTimestamp - 1, + lockedEpoch - 1, + PublicKey.unique() + ), + lamports: LAMPORTS_PER_SOL * 6, + staker, + withdrawer, + }) + const [stakeAccount1Data] = await getAndCheckStakeAccount( + provider, + stakeAccount1, + StakeStates.Delegated + ) + const [stakeAccount2Data] = await getAndCheckStakeAccount( + provider, + stakeAccount2, + StakeStates.Delegated + ) + expect(Number(clock.epoch)).toBeGreaterThan(lockedEpoch) + expect(Number(clock.unixTimestamp)).toBeGreaterThan(lockedTimestamp) + expect(stakeAccount1Data.Stake?.meta.lockup.epoch.toString()).toEqual( + lockedEpoch.toString() + ) + expect(stakeAccount2Data.Stake?.meta.lockup.epoch.toString()).toEqual( + (lockedEpoch - 1).toString() + ) + + console.log( + '1. MERGING delegated to same vote account, non-locked stakes with different lockup meta data' + ) + const mergeIx = StakeProgram.merge({ + stakePubkey: stakeAccount1, + sourceStakePubKey: stakeAccount2, + authorizedPubkey: staker.publicKey, + }) + await bankrunExecuteIx(provider, [provider.wallet, staker], mergeIx) + await assertNotExist(provider, stakeAccount2) + const [stakeAccountData, stakeAccountInfo] = await getAndCheckStakeAccount( + provider, + stakeAccount1, + StakeStates.Delegated + ) + // lamports matches the sum of the two merged accounts + expect(stakeAccountInfo.lamports).toEqual(11 * LAMPORTS_PER_SOL) + expect(stakeAccountData.Stake?.stake.delegation.stake.toString()).toEqual( + (11 * LAMPORTS_PER_SOL - rentExemptStake).toString() + ) + // lockup is the same as the first account + expect(stakeAccountData.Stake?.meta.lockup.epoch.toString()).toEqual( + lockedEpoch.toString() + ) + expect( + stakeAccountData.Stake?.meta.lockup.unixTimestamp.toString() + ).toEqual(lockedTimestamp.toString()) + expect(stakeAccountData.Stake?.meta.lockup.custodian.toBase58()).toEqual( + lockedCustodian.toBase58() + ) + + console.log( + '2. MERGING deactivated to activated not possible, lockup metadata is different' + ) + const { stakeAccount: stakeAccountLocked } = await delegatedStakeAccount({ + provider, + voteAccountToDelegate: voteAccount, + lockup: new Lockup(0, Number(clock.epoch) + 1, lockedCustodian), + lamports: LAMPORTS_PER_SOL * 5, + staker, + withdrawer, + }) + const [stakeAccountLockedData] = await getAndCheckStakeAccount( + provider, + stakeAccountLocked + ) + expect( + stakeAccountLockedData.Stake?.stake.delegation.stake.toString() + ).toEqual((5 * LAMPORTS_PER_SOL - rentExemptStake).toString()) + + // merging stakeAccountLocked --> stakeAccount1 + const mergeWithLockedIx = StakeProgram.merge({ + stakePubkey: stakeAccount1, + sourceStakePubKey: stakeAccountLocked, + authorizedPubkey: staker.publicKey, + }) + await verifyErrorMessage( + provider, + '2.', + 'custom program error: 0x6', // MergeMismatch + [provider.wallet, staker], + mergeWithLockedIx + ) + + console.log('3. MERGING deactivated with different delegation') + const otherVoteAccount = (await createVoteAccount(provider, rentExemptVote)) + .voteAccount + const delegateIx = StakeProgram.delegate({ + stakePubkey: stakeAccountLocked, + authorizedPubkey: staker.publicKey, + votePubkey: otherVoteAccount, + }) + const deactivateIx1 = StakeProgram.deactivate({ + stakePubkey: stakeAccount1, + authorizedPubkey: staker.publicKey, + }) + const deactivateIx = StakeProgram.deactivate({ + stakePubkey: stakeAccountLocked, + authorizedPubkey: staker.publicKey, + }) + await bankrunExecuteIx( + provider, + [provider.wallet, staker], + delegateIx, + deactivateIx1, + deactivateIx + ) + // warping to next epoch to be sure the deactivation is done + warpToEpoch(provider, Number(clock.epoch) + 2) + const [deactivatedAccount1Data] = await getAndCheckStakeAccount( + provider, + stakeAccount1 + ) + const [deactivatedLockedData] = await getAndCheckStakeAccount( + provider, + stakeAccountLocked + ) + expect( + deactivatedAccount1Data.Stake?.stake.delegation.voterPubkey.toBase58() + ).toEqual(voteAccount.toBase58()) + expect( + deactivatedLockedData.Stake?.stake.delegation.voterPubkey.toBase58() + ).toEqual(otherVoteAccount.toBase58()) + + const mergeIxDeactivated = StakeProgram.merge({ + stakePubkey: stakeAccount1, + sourceStakePubKey: stakeAccountLocked, + authorizedPubkey: staker.publicKey, + }) + await bankrunExecuteIx( + provider, + [provider.wallet, staker], + mergeIxDeactivated + ) + await assertNotExist(provider, stakeAccountLocked) + const [, mergedDeactivatedInfo] = await getAndCheckStakeAccount( + provider, + stakeAccount1, + StakeStates.Delegated + ) + expect(mergedDeactivatedInfo.lamports.toString()).toEqual( + (16 * LAMPORTS_PER_SOL).toString() + ) + }) + + /** + * What happen after split of stake account with authorities and lockup, + * are they maintained as in the original? + * - yes, they are maintained + */ + it('splitting stake accounts', async () => { + const clock = await provider.context.banksClient.getClock() + const custodian = Keypair.generate() + const lockedLockup = new Lockup( + 0, + Number(clock.epoch) + 1, + custodian.publicKey + ) + const lamports = LAMPORTS_PER_SOL * 5 + const { + stakeAccount: stakeAccount1, + staker, + voteAccount, + } = await delegatedStakeAccount({ + provider, + lockup: lockedLockup, + lamports, + }) + const stakeAccount2 = Keypair.generate() + const spitLamports = LAMPORTS_PER_SOL * 2 + expect(spitLamports).toBeLessThan(lamports) + const splitIx = StakeProgram.split({ + stakePubkey: stakeAccount1, + authorizedPubkey: staker.publicKey, + splitStakePubkey: stakeAccount2.publicKey, + lamports: spitLamports, + }) + await bankrunExecuteIx( + provider, + [provider.wallet, staker, stakeAccount2], + splitIx + ) + const [stakeAccount1Data, stakeAccount1Info] = + await getAndCheckStakeAccount( + provider, + stakeAccount1, + StakeStates.Delegated + ) + const [stakeAccount2Data, stakeAccount2Info] = + await getAndCheckStakeAccount( + provider, + stakeAccount2.publicKey, + StakeStates.Delegated + ) + expect(stakeAccount1Data.Stake?.meta.lockup).toEqual(lockedLockup) + expect(stakeAccount2Data.Stake?.meta.lockup).toEqual(lockedLockup) + expect(stakeAccount1Data.Stake?.stake.delegation.stake.toNumber()).toEqual( + lamports - spitLamports - rentExemptStake + ) + expect(stakeAccount2Data.Stake?.stake.delegation.stake.toNumber()).toEqual( + spitLamports - rentExemptStake + ) + expect(stakeAccount1Info.lamports).toEqual(lamports - spitLamports) + expect(stakeAccount2Info.lamports).toEqual(spitLamports) + expect(stakeAccount2Data.Stake?.meta.authorized).toEqual( + stakeAccount1Data.Stake?.meta.authorized + ) + expect(stakeAccount1Data.Stake?.stake.delegation.voterPubkey).toEqual( + voteAccount + ) + expect(stakeAccount2Data.Stake?.stake.delegation.voterPubkey).toEqual( + voteAccount + ) + }) }) diff --git a/packages/validator-bonds-sdk/__tests__/bankrun/utils/bankrun.ts b/packages/validator-bonds-sdk/__tests__/bankrun/utils/bankrun.ts deleted file mode 100644 index 37ff2dbb..00000000 --- a/packages/validator-bonds-sdk/__tests__/bankrun/utils/bankrun.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Wallet as WalletInterface } from '@coral-xyz/anchor/dist/cjs/provider' -import { ValidatorBondsProgram, getProgram } from '../../../src' -import { startAnchor } from 'solana-bankrun' -import { BankrunProvider } from 'anchor-bankrun' -import { PublicKey, Signer, Transaction } from '@solana/web3.js' -import { instanceOfWallet } from '@marinade.finance/web3js-common' - -export async function initBankrunTest(programId?: PublicKey): Promise<{ - program: ValidatorBondsProgram - provider: BankrunProvider -}> { - const context = await startAnchor('./', [], []) - const provider = new BankrunProvider(context) - console.dir(provider.context.banksClient) - return { - program: getProgram({ connection: provider, programId }), - provider, - } -} - -export async function bankrunTransaction( - provider: BankrunProvider -): Promise { - const bh = await provider.context.banksClient.getLatestBlockhash() - const lastValidBlockHeight = ( - bh === null ? Number.MAX_VALUE : bh[1] - ) as number - return new Transaction({ - feePayer: provider.publicKey, - blockhash: provider.context.lastBlockhash, - lastValidBlockHeight, - }) -} - -export async function bankrunExecute( - provider: BankrunProvider, - tx: Transaction, - signers: (WalletInterface | Signer)[] -) { - for (const signer of signers) { - if (instanceOfWallet(signer)) { - await signer.signTransaction(tx) - } else { - tx.partialSign(signer) - } - } - await provider.context.banksClient.processTransaction(tx) -} diff --git a/packages/validator-bonds-sdk/__tests__/test-validator/configureBond.spec.ts b/packages/validator-bonds-sdk/__tests__/test-validator/configureBond.spec.ts new file mode 100644 index 00000000..85f24786 --- /dev/null +++ b/packages/validator-bonds-sdk/__tests__/test-validator/configureBond.spec.ts @@ -0,0 +1,85 @@ +import { Keypair, PublicKey } from '@solana/web3.js' +import { + CONFIGURE_BOND_EVENT, + ConfigureBondEvent, + ValidatorBondsProgram, + configureBondInstruction, + getBond, +} from '../../src' +import { initTest } from './testValidator' +import { + executeInitBondInstruction, + executeInitConfigInstruction, +} from '../utils/testTransactions' +import { ExtendedProvider } from '../utils/provider' + +describe('Validator Bonds configure bond', () => { + let provider: ExtendedProvider + let program: ValidatorBondsProgram + let configAccount: PublicKey + + beforeAll(async () => { + ;({ provider, program } = await initTest()) + }) + + afterAll(async () => { + // workaround: "Jest has detected the following 1 open handle", see `initConfig.spec.ts` + await new Promise(resolve => setTimeout(resolve, 500)) + }) + + beforeEach(async () => { + ;({ configAccount } = await executeInitConfigInstruction({ + program, + provider, + })) + }) + + it('configure bond', async () => { + const { bondAccount, bondAuthority } = await executeInitBondInstruction( + program, + provider, + configAccount, + undefined, + undefined, + undefined, + 22 + ) + + const event = new Promise(resolve => { + const listener = program.addEventListener( + CONFIGURE_BOND_EVENT, + async event => { + await program.removeEventListener(listener) + resolve(event) + } + ) + }) + + const newBondAuthority = Keypair.generate() + const { instruction } = await configureBondInstruction({ + program, + bondAccount, + authority: bondAuthority, + newBondAuthority: newBondAuthority.publicKey, + newRevenueShareHundredthBps: 31, + }) + await provider.sendIx([bondAuthority], instruction) + + const bondData = await getBond(program, bondAccount) + expect(bondData.authority).toEqual(newBondAuthority.publicKey) + expect(bondData.config).toEqual(configAccount) + expect(bondData.revenueShare).toEqual({ hundredthBps: 31 }) + expect(bondData.authority).toEqual(newBondAuthority.publicKey) + + await event.then(e => { + expect(e.bondAuthority).toEqual({ + old: bondAuthority.publicKey, + new: newBondAuthority.publicKey, + }) + expect(e.revenueShare).toEqual({ + old: { hundredthBps: 22 }, + new: { hundredthBps: 31 }, + }) + }) + }) +}) diff --git a/packages/validator-bonds-sdk/__tests__/test-validator/configureConfig.spec.ts b/packages/validator-bonds-sdk/__tests__/test-validator/configureConfig.spec.ts new file mode 100644 index 00000000..cfbcd03c --- /dev/null +++ b/packages/validator-bonds-sdk/__tests__/test-validator/configureConfig.spec.ts @@ -0,0 +1,112 @@ +import { Keypair, LAMPORTS_PER_SOL, PublicKey } from '@solana/web3.js' +import { + CONFIGURE_CONFIG_EVENT, + Config, + ConfigureConfigEvent, + ValidatorBondsProgram, + configureConfigInstruction, + getConfig, +} from '../../src' +import { ProgramAccount } from '@coral-xyz/anchor' +import { AnchorExtendedProvider, initTest } from './testValidator' +import { transaction } from '@marinade.finance/anchor-common' +import { executeTxSimple } from '@marinade.finance/web3js-common' +import { executeInitConfigInstruction } from '../utils/testTransactions' + +describe('Validator Bonds configure config', () => { + let provider: AnchorExtendedProvider + let program: ValidatorBondsProgram + let configInitialized: ProgramAccount + let adminAuthority: Keypair + + beforeAll(async () => { + ;({ provider, program } = await initTest()) + }) + + afterAll(async () => { + // workaround: "Jest has detected the following 1 open handle", see `initConfig.spec.ts` + await new Promise(resolve => setTimeout(resolve, 500)) + }) + + beforeEach(async () => { + const { configAccount, adminAuthority: adminAuth } = + await executeInitConfigInstruction({ + program, + provider, + epochsToClaimSettlement: 1, + withdrawLockupEpochs: 2, + }) + configInitialized = { + publicKey: configAccount, + account: await getConfig(program, configAccount), + } + expect(configInitialized.account.adminAuthority).toEqual( + adminAuth.publicKey + ) + expect(configInitialized.account.epochsToClaimSettlement).toEqual(1) + expect(configInitialized.account.withdrawLockupEpochs).toEqual(2) + adminAuthority = adminAuth + }) + + it('configure config', async () => { + const newAdminAuthority = Keypair.generate() + const newOperatorAuthority = PublicKey.unique() + + const event = new Promise(resolve => { + const listener = program.addEventListener( + CONFIGURE_CONFIG_EVENT, + async event => { + await program.removeEventListener(listener) + resolve(event) + } + ) + }) + + const tx = await transaction(provider) + const { instruction } = await configureConfigInstruction({ + program, + configAccount: configInitialized.publicKey, + adminAuthority, + newOperator: newOperatorAuthority, + newAdmin: newAdminAuthority.publicKey, + newEpochsToClaimSettlement: 100, + newWithdrawLockupEpochs: 103, + newMinimumStakeLamports: 1001, + }) + tx.add(instruction) + await executeTxSimple(provider.connection, tx, [ + provider.wallet, + adminAuthority, + ]) + + const configData = await getConfig(program, configInitialized.publicKey) + expect(configData.adminAuthority).toEqual(newAdminAuthority.publicKey) + expect(configData.operatorAuthority).toEqual(newOperatorAuthority) + expect(configData.epochsToClaimSettlement).toEqual(100) + expect(configData.withdrawLockupEpochs).toEqual(103) + expect(configData.minimumStakeLamports).toEqual(1001) + + await event.then(e => { + expect(e.adminAuthority).toEqual({ + old: adminAuthority.publicKey, + new: newAdminAuthority.publicKey, + }) + expect(e.operatorAuthority).toEqual({ + old: configInitialized.account.operatorAuthority, + new: newOperatorAuthority, + }) + expect(e.epochsToClaimSettlement).toEqual({ + old: configInitialized.account.epochsToClaimSettlement, + new: 100, + }) + expect(e.withdrawLockupEpochs).toEqual({ + old: configInitialized.account.withdrawLockupEpochs, + new: 103, + }) + expect(e.minimumStakeLamports).toEqual({ + old: LAMPORTS_PER_SOL, + new: 1001, + }) + }) + }) +}) diff --git a/packages/validator-bonds-sdk/__tests__/test-validator/fundBond.spec.ts b/packages/validator-bonds-sdk/__tests__/test-validator/fundBond.spec.ts new file mode 100644 index 00000000..6333846c --- /dev/null +++ b/packages/validator-bonds-sdk/__tests__/test-validator/fundBond.spec.ts @@ -0,0 +1,103 @@ +import { LAMPORTS_PER_SOL, PublicKey } from '@solana/web3.js' +import { + FUND_BOND_EVENT, + FundBondEvent, + ValidatorBondsProgram, + fundBondInstruction, + getStakeAccount, + withdrawerAuthority, +} from '../../src' +import { initTest, waitForStakeAccountActivation } from './testValidator' +import { + executeInitBondInstruction, + executeInitConfigInstruction, +} from '../utils/testTransactions' +import { ExtendedProvider } from '../utils/provider' +import { createVoteAccount, delegatedStakeAccount } from '../utils/staking' + +describe('Validator Bonds fund bond', () => { + let provider: ExtendedProvider + let program: ValidatorBondsProgram + let configAccount: PublicKey + let bondAccount: PublicKey + let validatorVoteAccount: PublicKey + + beforeAll(async () => { + ;({ provider, program } = await initTest()) + }) + + afterAll(async () => { + // workaround: "Jest has detected the following 1 open handle", see `initConfig.spec.ts` + await new Promise(resolve => setTimeout(resolve, 500)) + }) + + beforeEach(async () => { + ;({ configAccount } = await executeInitConfigInstruction({ + program, + provider, + })) + const { voteAccount, authorizedWithdrawer } = await createVoteAccount( + provider + ) + validatorVoteAccount = voteAccount + ;({ bondAccount } = await executeInitBondInstruction( + program, + provider, + configAccount, + undefined, + validatorVoteAccount, + authorizedWithdrawer + )) + }) + + it('fund bond', async () => { + const event = new Promise(resolve => { + const listener = program.addEventListener( + FUND_BOND_EVENT, + async event => { + await program.removeEventListener(listener) + resolve(event) + } + ) + }) + + const { stakeAccount, withdrawer } = await delegatedStakeAccount({ + provider, + lamports: LAMPORTS_PER_SOL * 2, + voteAccountToDelegate: validatorVoteAccount, + }) + console.debug( + `Waiting for activation of stake account: ${stakeAccount.toBase58()}` + ) + await waitForStakeAccountActivation({ + stakeAccount, + connection: provider.connection, + }) + + const { instruction } = await fundBondInstruction({ + program, + bondAccount, + configAccount, + stakeAccount, + authority: withdrawer, + }) + await provider.sendIx([withdrawer], instruction) + + const stakeAccountData = await getStakeAccount(provider, stakeAccount) + const [bondWithdrawer] = withdrawerAuthority( + configAccount, + program.programId + ) + expect(stakeAccountData.staker).toEqual(bondWithdrawer) + expect(stakeAccountData.withdrawer).toEqual(bondWithdrawer) + expect(stakeAccountData.isLockedUp).toBeFalsy() + + await event.then(e => { + expect(e.bond).toEqual(bondAccount) + expect(e.depositedAmount).toEqual(2 * LAMPORTS_PER_SOL) + expect(e.stakeAccount).toEqual(stakeAccount) + expect(e.stakeAuthoritySigner).toEqual(withdrawer.publicKey) + expect(e.validatorVote).toEqual(validatorVoteAccount) + }) + }) +}) diff --git a/packages/validator-bonds-sdk/__tests__/test-validator/initBond.spec.ts b/packages/validator-bonds-sdk/__tests__/test-validator/initBond.spec.ts new file mode 100644 index 00000000..351d66d1 --- /dev/null +++ b/packages/validator-bonds-sdk/__tests__/test-validator/initBond.spec.ts @@ -0,0 +1,158 @@ +import { Keypair, PublicKey, Signer } from '@solana/web3.js' +import { + INIT_BOND_EVENT, + InitBondEvent, + ValidatorBondsProgram, + bondAddress, + findBonds, + getBond, + initBondInstruction, +} from '../../src' +import { initTest } from './testValidator' +import { transaction } from '@marinade.finance/anchor-common' +import { Wallet, splitAndExecuteTx } from '@marinade.finance/web3js-common' +import { signer } from '../utils/helpers' +import { executeInitConfigInstruction } from '../utils/testTransactions' +import { ExtendedProvider } from '../utils/provider' +import { createVoteAccount } from '../utils/staking' +import { AnchorProvider } from '@coral-xyz/anchor' + +describe('Validator Bonds init bond', () => { + let provider: ExtendedProvider + let program: ValidatorBondsProgram + let configAccount: PublicKey + + beforeAll(async () => { + ;({ provider, program } = await initTest()) + }) + + afterAll(async () => { + // workaround: "Jest has detected the following 1 open handle", see `initConfig.spec.ts` + await new Promise(resolve => setTimeout(resolve, 500)) + }) + + beforeEach(async () => { + ;({ configAccount } = await executeInitConfigInstruction({ + program, + provider, + })) + }) + + it('init bond', async () => { + const event = new Promise(resolve => { + const listener = program.addEventListener( + INIT_BOND_EVENT, + async event => { + await program.removeEventListener(listener) + resolve(event) + } + ) + }) + + const { voteAccount: validatorVoteAccount, authorizedWithdrawer } = + await createVoteAccount(provider) + const bondAuthority = PublicKey.unique() + const { instruction, bondAccount } = await initBondInstruction({ + program, + configAccount, + bondAuthority, + revenueShareHundredthBps: 22, + validatorVoteAccount, + validatorVoteWithdrawer: authorizedWithdrawer.publicKey, + }) + await provider.sendIx([authorizedWithdrawer], instruction) + + const bondsDataFromList = await findBonds({ + program, + config: configAccount, + validatorVoteAccount, + bondAuthority, + }) + expect(bondsDataFromList.length).toEqual(1) + + const bondData = await getBond(program, bondAccount) + + const [bondCalculatedAddress, bondBump] = bondAddress( + configAccount, + validatorVoteAccount, + program.programId + ) + expect(bondCalculatedAddress).toEqual(bondAccount) + expect(bondData.authority).toEqual(bondAuthority) + expect(bondData.bump).toEqual(bondBump) + expect(bondData.config).toEqual(configAccount) + expect(bondData.revenueShare).toEqual({ hundredthBps: 22 }) + expect(bondData.validatorVoteAccount).toEqual(validatorVoteAccount) + + // Ensure the event listener was called + await event.then(e => { + expect(e.authority).toEqual(bondAuthority) + expect(e.bondBump).toEqual(bondBump) + expect(e.configAddress).toEqual(configAccount) + expect(e.revenueShare).toEqual({ hundredthBps: 22 }) + expect(e.validatorVoteAccount).toEqual(validatorVoteAccount) + expect(e.validatorVoteWithdrawer).toEqual(authorizedWithdrawer.publicKey) + }) + }) + + it('find bonds', async () => { + const bondAuthority = Keypair.generate().publicKey + + const tx = await transaction(provider) + const signers: (Signer | Wallet)[] = [ + (provider as unknown as AnchorProvider).wallet, + ] + + const numberOfBonds = 24 + + const voteAccounts: [PublicKey, Keypair][] = [] + for (let i = 1; i <= numberOfBonds; i++) { + const { voteAccount: validatorVoteAccount, authorizedWithdrawer } = + await createVoteAccount(provider) + voteAccounts.push([validatorVoteAccount, authorizedWithdrawer]) + signers.push(signer(authorizedWithdrawer)) + } + + for (let i = 1; i <= numberOfBonds; i++) { + const [validatorVoteAccount, validatorVoteWithdrawer] = + voteAccounts[i - 1] + const { instruction } = await initBondInstruction({ + program, + configAccount, + bondAuthority: bondAuthority, + revenueShareHundredthBps: 100, + validatorVoteAccount, + validatorVoteWithdrawer, + }) + tx.add(instruction) + } + await splitAndExecuteTx({ + connection: provider.connection, + transaction: tx, + signers, + errMessage: 'Failed to init bonds', + }) + + let bondDataFromList = await findBonds({ program, bondAuthority }) + expect(bondDataFromList.length).toEqual(numberOfBonds) + + bondDataFromList = await findBonds({ program, config: configAccount }) + expect(bondDataFromList.length).toEqual(numberOfBonds) + + for (let i = 1; i <= numberOfBonds; i++) { + const [validatorVoteAccount] = voteAccounts[i - 1] + bondDataFromList = await findBonds({ + program, + validatorVoteAccount, + }) + expect(bondDataFromList.length).toEqual(1) + } + + bondDataFromList = await findBonds({ + program, + bondAuthority, + config: configAccount, + }) + expect(bondDataFromList.length).toEqual(numberOfBonds) + }) +}) diff --git a/packages/validator-bonds-sdk/__tests__/test-validator/initConfig.spec.ts b/packages/validator-bonds-sdk/__tests__/test-validator/initConfig.spec.ts index b294092c..e3965ae5 100644 --- a/packages/validator-bonds-sdk/__tests__/test-validator/initConfig.spec.ts +++ b/packages/validator-bonds-sdk/__tests__/test-validator/initConfig.spec.ts @@ -8,20 +8,20 @@ import { initConfigInstruction, } from '../../src' import { AnchorProvider } from '@coral-xyz/anchor' -import { initTest } from './utils' +import { initTest } from './testValidator' import { transaction } from '@marinade.finance/anchor-common' import { Wallet, executeTxSimple, splitAndExecuteTx, } from '@marinade.finance/web3js-common' +import { signer, signerWithPubkey } from '../utils/helpers' -describe('Validator Bonds config account tests', () => { +describe('Validator Bonds init config', () => { let provider: AnchorProvider let program: ValidatorBondsProgram beforeAll(async () => { - // eslint-disable-next-line @typescript-eslint/no-extra-semi ;({ provider, program } = await initTest()) }) @@ -53,18 +53,22 @@ describe('Validator Bonds config account tests', () => { const tx = await transaction(provider) - const { keypair, instruction } = await initConfigInstruction({ + const { configAccount, instruction } = await initConfigInstruction({ program, - adminAuthority, - operatorAuthority, + admin: adminAuthority, + operator: operatorAuthority, epochsToClaimSettlement: 1, withdrawLockupEpochs: 2, }) tx.add(instruction) - await executeTxSimple(provider.connection, tx, [provider.wallet, keypair!]) + const [configSigner, configAddress] = signerWithPubkey(configAccount) + await executeTxSimple(provider.connection, tx, [ + provider.wallet, + configSigner, + ]) // Ensure the account was created - const configAccountAddress = keypair!.publicKey + const configAccountAddress = configAddress const configData = await getConfig(program, configAccountAddress) const configDataFromList = await findConfigs({ program, adminAuthority }) @@ -93,15 +97,15 @@ describe('Validator Bonds config account tests', () => { const numberOfConfigs = 17 for (let i = 1; i <= numberOfConfigs; i++) { - const { keypair, instruction } = await initConfigInstruction({ + const { configAccount, instruction } = await initConfigInstruction({ program, - adminAuthority, - operatorAuthority, + admin: adminAuthority, + operator: operatorAuthority, epochsToClaimSettlement: i, withdrawLockupEpochs: i + 1, }) tx.add(instruction) - signers.push(keypair!) + signers.push(signer(configAccount)) } await splitAndExecuteTx({ connection: provider.connection, diff --git a/packages/validator-bonds-sdk/__tests__/test-validator/merge.spec.ts b/packages/validator-bonds-sdk/__tests__/test-validator/merge.spec.ts new file mode 100644 index 00000000..be64a745 --- /dev/null +++ b/packages/validator-bonds-sdk/__tests__/test-validator/merge.spec.ts @@ -0,0 +1,106 @@ +import { LAMPORTS_PER_SOL, PublicKey } from '@solana/web3.js' +import { + MERGE_EVENT, + MergeEvent, + ValidatorBondsProgram, + getStakeAccount, + mergeInstruction, + withdrawerAuthority, +} from '../../src' +import { initTest } from './testValidator' +import { executeInitConfigInstruction } from '../utils/testTransactions' +import { ExtendedProvider } from '../utils/provider' +import { authorizeStakeAccount, delegatedStakeAccount } from '../utils/staking' + +describe('Validator Bonds fund bond', () => { + let provider: ExtendedProvider + let program: ValidatorBondsProgram + let configAccount: PublicKey + + beforeAll(async () => { + ;({ provider, program } = await initTest()) + }) + + afterAll(async () => { + // workaround: "Jest has detected the following 1 open handle", see `initConfig.spec.ts` + await new Promise(resolve => setTimeout(resolve, 500)) + }) + + beforeEach(async () => { + ;({ configAccount } = await executeInitConfigInstruction({ + program, + provider, + })) + }) + + it('merge', async () => { + const event = new Promise(resolve => { + const listener = program.addEventListener(MERGE_EVENT, async event => { + await program.removeEventListener(listener) + resolve(event) + }) + }) + + const [bondWithdrawer] = withdrawerAuthority( + configAccount, + program.programId + ) + const [lamports1, lamports2] = [2, 3].map(n => n * LAMPORTS_PER_SOL) + const { + stakeAccount: stakeAccount1, + withdrawer: withdrawer1, + voteAccount, + } = await delegatedStakeAccount({ + provider, + lamports: lamports1, + lockup: undefined, + }) + await authorizeStakeAccount({ + provider, + authority: withdrawer1, + stakeAccount: stakeAccount1, + staker: bondWithdrawer, + withdrawer: bondWithdrawer, + }) + const { stakeAccount: stakeAccount2, withdrawer: withdrawer2 } = + await delegatedStakeAccount({ + provider, + lamports: lamports2, + lockup: undefined, + voteAccountToDelegate: voteAccount, + }) + await authorizeStakeAccount({ + provider, + authority: withdrawer2, + stakeAccount: stakeAccount2, + staker: bondWithdrawer, + withdrawer: bondWithdrawer, + }) + + const { instruction } = await mergeInstruction({ + program, + configAccount, + sourceStakeAccount: stakeAccount2, + destinationStakeAccount: stakeAccount1, + }) + await provider.sendIx([], instruction) + + const stakeAccountData = await getStakeAccount(provider, stakeAccount1) + expect(stakeAccountData.staker).toEqual(bondWithdrawer) + expect(stakeAccountData.withdrawer).toEqual(bondWithdrawer) + expect(stakeAccountData.isLockedUp).toBeFalsy() + expect(stakeAccountData.balanceLamports).toEqual(lamports1 + lamports2) + expect( + provider.connection.getAccountInfo(stakeAccount2) + ).resolves.toBeNull() + + await event.then(e => { + expect(e.config).toEqual(configAccount) + expect(e.destinationStake).toEqual(stakeAccount1) + expect(e.destinationDelegation?.voterPubkey).toEqual(voteAccount) + expect(e.sourceStake).toEqual(stakeAccount2) + expect(e.sourceDelegation?.voterPubkey).toEqual(voteAccount) + expect(e.stakerAuthority).toEqual(bondWithdrawer) + }) + }) +}) diff --git a/packages/validator-bonds-sdk/__tests__/test-validator/testValidator.ts b/packages/validator-bonds-sdk/__tests__/test-validator/testValidator.ts new file mode 100644 index 00000000..de4632c4 --- /dev/null +++ b/packages/validator-bonds-sdk/__tests__/test-validator/testValidator.ts @@ -0,0 +1,122 @@ +import * as anchor from '@coral-xyz/anchor' +import { Wallet as WalletInterface } from '@coral-xyz/anchor/dist/cjs/provider' +import { AnchorProvider } from '@coral-xyz/anchor' +import { ValidatorBondsProgram, getProgram, getStakeAccount } from '../../src' +import { ExtendedProvider } from '../utils/provider' +import { + Connection, + PublicKey, + Signer, + Transaction, + TransactionInstruction, + TransactionInstructionCtorFields, +} from '@solana/web3.js' +import { transaction } from '@marinade.finance/anchor-common' +import { executeTxSimple } from '@marinade.finance/web3js-common' +import { sleep } from '../utils/helpers' + +export class AnchorExtendedProvider + extends AnchorProvider + implements ExtendedProvider +{ + async sendIx( + signers: (WalletInterface | Signer)[], + ...ixes: ( + | Transaction + | TransactionInstruction + | TransactionInstructionCtorFields + )[] + ): Promise { + const tx = await transaction(this) + tx.add(...ixes) + await executeTxSimple(this.connection, tx, [this.wallet, ...signers]) + } + + get walletPubkey(): PublicKey { + return this.wallet.publicKey + } +} + +export async function initTest(): Promise<{ + program: ValidatorBondsProgram + provider: AnchorExtendedProvider +}> { + const anchorProvider = AnchorExtendedProvider.env() + const provider = new AnchorExtendedProvider( + anchorProvider.connection, + anchorProvider.wallet, + { ...anchorProvider.opts, skipPreflight: true } + ) + anchor.setProvider(provider) + return { program: getProgram(provider), provider } +} + +// NOTE: the Anchor.toml configures slots_per_epoch to 32, +export async function waitForStakeAccountActivation({ + stakeAccount, + connection, + timeoutSeconds = 30, + activatedAtLeastFor = 0, +}: { + stakeAccount: PublicKey + connection: Connection + timeoutSeconds?: number + activatedAtLeastFor?: number +}) { + // 1. waiting for the stake account to be activated + { + const startTime = Date.now() + let stakeStatus = await connection.getStakeActivation(stakeAccount) + while (stakeStatus.state !== 'active') { + await sleep(1000) + stakeStatus = await connection.getStakeActivation(stakeAccount) + if (Date.now() - startTime > timeoutSeconds * 1000) { + throw new Error( + `Stake account ${stakeAccount.toBase58()} was not activated in timeout of ${timeoutSeconds} seconds` + ) + } + } + } + + // 2. the stake account is active, but it needs to be active for at least waitForEpochs epochs + if (activatedAtLeastFor > 0) { + const stakeAccountData = await getStakeAccount(connection, stakeAccount) + const stakeAccountActivationEpoch = stakeAccountData.activationEpoch + if (stakeAccountActivationEpoch === null) { + throw new Error( + 'Expected stake account to be already activated. Unexpected setup error stake account:' + + stakeAccountData + ) + } + + const startTime = Date.now() + let currentEpoch = (await connection.getEpochInfo()).epoch + if ( + currentEpoch < + stakeAccountActivationEpoch.toNumber() + activatedAtLeastFor + ) { + console.debug( + `Waiting for the stake account ${stakeAccount.toBase58()} to be active at least for ${activatedAtLeastFor} epochs ` + + `currently active for ${ + currentEpoch - stakeAccountActivationEpoch.toNumber() + } epoch(s)` + ) + } + while ( + currentEpoch < + stakeAccountActivationEpoch.toNumber() + activatedAtLeastFor + ) { + if (Date.now() - startTime > timeoutSeconds * 1000) { + throw new Error( + `Stake account ${stakeAccount.toBase58()} was activated but timeout ${timeoutSeconds} elapsed when waiting ` + + `for ${activatedAtLeastFor} epochs the account to be activated, it's activated only for ` + + `${ + currentEpoch - stakeAccountActivationEpoch.toNumber() + } epochs at this time` + ) + } + await sleep(1000) + currentEpoch = (await connection.getEpochInfo()).epoch + } + } +} diff --git a/packages/validator-bonds-sdk/__tests__/test-validator/utils.ts b/packages/validator-bonds-sdk/__tests__/test-validator/utils.ts deleted file mode 100644 index c4628e3b..00000000 --- a/packages/validator-bonds-sdk/__tests__/test-validator/utils.ts +++ /dev/null @@ -1,16 +0,0 @@ -import * as anchor from '@coral-xyz/anchor' -import { AnchorProvider } from '@coral-xyz/anchor' -import { ValidatorBondsProgram, getProgram } from '../../src' - -export async function initTest(): Promise<{ - program: ValidatorBondsProgram - provider: AnchorProvider -}> { - if (process.env.ANCHOR_PROVIDER_URL?.includes('localhost')) { - // workaround to: https://github.com/coral-xyz/anchor/pull/2725 - process.env.ANCHOR_PROVIDER_URL = 'http://127.0.0.1:8899' - } - const provider = AnchorProvider.env() as anchor.AnchorProvider - provider.opts.skipPreflight = true - return { program: getProgram(provider), provider } -} diff --git a/packages/validator-bonds-sdk/__tests__/utils/helpers.ts b/packages/validator-bonds-sdk/__tests__/utils/helpers.ts new file mode 100644 index 00000000..58aa5bb3 --- /dev/null +++ b/packages/validator-bonds-sdk/__tests__/utils/helpers.ts @@ -0,0 +1,136 @@ +import { Wallet as WalletInterface } from '@coral-xyz/anchor/dist/cjs/provider' +import { ExtendedProvider } from './provider' +import { + Keypair, + PublicKey, + Signer, + Transaction, + TransactionInstruction, + TransactionInstructionCtorFields, +} from '@solana/web3.js' +import { Errors } from '../../src' +import { ExecutionError } from '@marinade.finance/web3js-common' + +type ToString = { toString(): string } + +export function checkErrorMessage(e: unknown, message: ToString): boolean { + return ( + typeof e === 'object' && + e !== null && + 'message' in e && + typeof e.message === 'string' && + e.message.includes(message.toString()) + ) +} + +export function checkAnchorErrorMessage( + e: unknown, + errorNumber: ToString | number, + errorMessage: string +) { + let decimalNumber: number + if (errorNumber.toString().startsWith('0x')) { + decimalNumber = parseInt(errorNumber.toString(), 16) + } else { + decimalNumber = parseInt(errorNumber.toString()) + } + const hexNumber = '0x' + decimalNumber.toString(16) + + let passed = false + let eToCheck = e + while (eToCheck !== null && !passed) { + passed = + checkErrorMessage(eToCheck, errorNumber) || + checkErrorMessage(eToCheck, hexNumber) + if (eToCheck instanceof ExecutionError && eToCheck.cause !== null) { + eToCheck = eToCheck.cause + } else { + eToCheck = null + } + } + if (!passed) { + throw new Error( + `Expected anchor error number ${errorNumber} within error ` + + `${(e as ToString).toString()}` + ) + } + + if (!Errors.get(decimalNumber)?.includes(errorMessage)) { + throw new Error( + `Expected anchor error message '${errorMessage}' within error anchor error number ` + + `${errorNumber}/'${Errors.get(decimalNumber)}'` + ) + } +} + +export async function verifyErrorMessage( + provider: ExtendedProvider, + info: string, + checkMessage: string, + signers: (WalletInterface | Signer)[], + ...ixes: ( + | Transaction + | TransactionInstruction + | TransactionInstructionCtorFields + )[] +) { + try { + await provider.sendIx(signers, ...ixes) + throw new Error(`Expected failure ${info}, but it hasn't happened`) + } catch (e) { + if (checkErrorMessage(e, checkMessage)) { + console.debug(`${info} expected error (check: '${checkMessage}')`, e) + } else { + console.error( + `${info} wrong failure thrown, expected error: '${checkMessage}'`, + e + ) + throw e + } + } +} + +export function isSinger( + key: PublicKey | Signer | Keypair | undefined +): key is Signer | Keypair { + return key !== undefined && 'secretKey' in key && 'publicKey' in key +} + +export function signer( + key: PublicKey | Signer | Keypair | undefined +): Signer | Keypair { + if (isSinger(key)) { + return key + } else { + throw new Error( + `Expected signer but it's not: ${ + key === undefined ? undefined : key.toBase58() + }` + ) + } +} + +export function pubkey( + key: PublicKey | Signer | Keypair | undefined +): PublicKey { + if (key === undefined) { + throw new Error("Expected pubkey or signer but it's undefined") + } + return isSinger(key) ? key.publicKey : key +} + +export function signerWithPubkey( + key: PublicKey | Signer | Keypair | undefined +): [Signer | Keypair, PublicKey] { + if (key === undefined) { + throw new Error("Expected pubkey or signer but it's undefined") + } + if (!isSinger(key)) { + throw new Error(`Expected signer but it's not: ${key.toBase58()}`) + } + return [key, key.publicKey] +} + +export const sleep = async (ms: number) => { + return new Promise(resolve => setTimeout(resolve, ms)) +} diff --git a/packages/validator-bonds-sdk/__tests__/utils/provider.ts b/packages/validator-bonds-sdk/__tests__/utils/provider.ts new file mode 100644 index 00000000..c27b20da --- /dev/null +++ b/packages/validator-bonds-sdk/__tests__/utils/provider.ts @@ -0,0 +1,22 @@ +import { Wallet as WalletInterface } from '@coral-xyz/anchor/dist/cjs/provider' +import { Provider } from '@coral-xyz/anchor' +import { + PublicKey, + Signer, + Transaction, + TransactionInstruction, + TransactionInstructionCtorFields, +} from '@solana/web3.js' + +export interface ExtendedProvider extends Provider { + sendIx( + signers: (WalletInterface | Signer)[], + ...ixes: ( + | Transaction + | TransactionInstruction + | TransactionInstructionCtorFields + )[] + ): Promise + + get walletPubkey(): PublicKey +} diff --git a/packages/validator-bonds-sdk/__tests__/utils/staking.ts b/packages/validator-bonds-sdk/__tests__/utils/staking.ts new file mode 100644 index 00000000..a4e71c33 --- /dev/null +++ b/packages/validator-bonds-sdk/__tests__/utils/staking.ts @@ -0,0 +1,363 @@ +import { deserializeUnchecked } from 'borsh' +import { Provider } from '@coral-xyz/anchor' +import { + AccountInfo, + Authorized, + Keypair, + Lockup, + PublicKey, + StakeProgram, + SystemProgram, + VoteProgram, + TransactionInstruction, + Transaction, + StakeAuthorizationLayout, +} from '@solana/web3.js' +import { ExtendedProvider } from './provider' +import { + StakeState, + STAKE_STATE_BORSH_SCHEMA, +} from '@marinade.finance/marinade-ts-sdk/dist/src/marinade-state/borsh/stake-state' +import assert from 'assert' +import { pubkey } from './helpers' + +// Depending if new vote account feature-set is gated on. +// It can be 3762 or 3736 +// https://github.com/solana-labs/solana-web3.js/blob/v1.87.6/packages/library-legacy/src/programs/vote.ts#L372 +// It may emit error: +// Failed to process transaction: transport transaction error: Error processing Instruction 1: invalid account data for instruction +export const VOTE_ACCOUNT_SIZE = 3762 + +// borrowed from https://github.com/marinade-finance/marinade-ts-sdk/blob/v5.0.6/src/marinade-state/marinade-state.ts#L234 +export function deserializeStakeState(data: Buffer): StakeState { + // The data's first 4 bytes are: u8 0x0 0x0 0x0 but borsh uses only the first byte to find the enum's value index. + // The next 3 bytes are unused and we need to get rid of them (or somehow fix the BORSH schema?) + const adjustedData = Buffer.concat([ + data.subarray(0, 1), // the first byte indexing the enum + data.subarray(4, data.length), // the first byte indexing the enum + ]) + return deserializeUnchecked( + STAKE_STATE_BORSH_SCHEMA, + StakeState, + adjustedData + ) +} + +/** + * SetLockup stake instruction params + * + * - If a lockup is not active, the withdraw authority or custodian may set a new lockup + * - If a lockup is active, the lockup custodian may update the lockup parameters + */ +export type SetLockupStakeParams = { + stakePubkey: PublicKey + authorizedPubkey: PublicKey + unixTimestamp?: number + epoch?: number + custodian?: PublicKey +} + +export function setLockup( + params: SetLockupStakeParams +): TransactionInstruction { + const { stakePubkey, authorizedPubkey, unixTimestamp, epoch, custodian } = + params + + const keys = [ + // Initialized stake account + { pubkey: stakePubkey, isSigner: false, isWritable: true }, + // Lockup authority or withdraw authority + { pubkey: authorizedPubkey, isSigner: true, isWritable: false }, + ] + + const instructionIndex = 6 + const instructionBuf = Buffer.alloc(4) + instructionBuf.writeUInt32LE(instructionIndex, 0) + let timestampBuf = Buffer.from([0]) + if (unixTimestamp) { + timestampBuf = Buffer.alloc(9) + timestampBuf.writeUInt8(1, 0) + timestampBuf.writeBigInt64LE(BigInt(unixTimestamp), 1) + } + let epochBuf = Buffer.from([0]) + if (epoch) { + epochBuf = Buffer.alloc(9) + epochBuf.writeUInt8(1, 0) + epochBuf.writeBigInt64LE(BigInt(epoch), 1) + } + let custodianBuf = Buffer.from([0]) + if (custodian) { + custodianBuf = Buffer.alloc(33) + custodianBuf.writeUInt8(1, 0) + custodianBuf.set(custodian.toBuffer(), 1) + } + + const instructionData = { + keys, + programId: StakeProgram.programId, + data: Buffer.from([ + ...instructionBuf, + ...timestampBuf, + ...epochBuf, + ...custodianBuf, + ]), + } + return new TransactionInstruction(instructionData) +} + +export async function getRentExemptVote( + provider: Provider, + rentExempt?: number +): Promise { + return ( + rentExempt || + (await provider.connection.getMinimumBalanceForRentExemption( + VOTE_ACCOUNT_SIZE + )) + ) +} + +export async function getRentExemptStake( + provider: Provider, + rentExempt?: number +): Promise { + return ( + rentExempt || + (await provider.connection.getMinimumBalanceForRentExemption( + StakeProgram.space + )) + ) +} + +export enum StakeStates { + Uninitialized, + Initialized, + Delegated, + RewardsPool, +} + +export async function getAndCheckStakeAccount( + provider: Provider, + account: PublicKey, + stakeStateCheck?: StakeStates +): Promise<[StakeState, AccountInfo]> { + let accountInfo: AccountInfo + try { + accountInfo = (await provider.connection.getAccountInfo( + account + )) as AccountInfo + } catch (e) { + console.error(e) + throw new Error(`Account ${account.toBase58()} does not exist on chain`) + } + expect(accountInfo).toBeDefined() + assert(accountInfo) + const stakeData = deserializeStakeState(accountInfo.data) + switch (stakeStateCheck) { + case StakeStates.Uninitialized: + expect(stakeData.Uninitialized).toBeDefined() + break + case StakeStates.Initialized: + expect(stakeData.Initialized).toBeDefined() + break + case StakeStates.Delegated: + expect(stakeData.Stake).toBeDefined() + break + case StakeStates.RewardsPool: + expect(stakeData.RewardsPool).toBeDefined() + break + } + return [stakeData, accountInfo] +} + +// ----- ENHANCED PROVIDER ----- +export type VoteAccountKeys = { + voteAccount: PublicKey + nodeIdentity: Keypair + authorizedVoter: Keypair + authorizedWithdrawer: Keypair +} + +export async function createVoteAccount( + provider: ExtendedProvider, + rentExempt?: number, + authorizedVoter?: Keypair, + authorizedWithdrawer?: Keypair +): Promise { + rentExempt = await getRentExemptVote(provider, rentExempt) + + const voteAccount = Keypair.generate() + const nodeIdentity = Keypair.generate() + authorizedVoter = authorizedVoter || Keypair.generate() + authorizedWithdrawer = authorizedWithdrawer || Keypair.generate() + + const ixCreate = SystemProgram.createAccount({ + fromPubkey: provider.walletPubkey, + newAccountPubkey: voteAccount.publicKey, + lamports: rentExempt, + space: VOTE_ACCOUNT_SIZE, + programId: VoteProgram.programId, + }) + const ixInitialize = VoteProgram.initializeAccount({ + votePubkey: voteAccount.publicKey, + nodePubkey: nodeIdentity.publicKey, + voteInit: { + authorizedVoter: authorizedVoter.publicKey, + authorizedWithdrawer: authorizedWithdrawer.publicKey, + commission: 0, + nodePubkey: nodeIdentity.publicKey, + }, + }) + + await provider.sendIx([voteAccount, nodeIdentity], ixCreate, ixInitialize) + return { + voteAccount: voteAccount.publicKey, + nodeIdentity, + authorizedVoter, + authorizedWithdrawer, + } +} + +export async function authorizeStakeAccount({ + provider, + stakeAccount, + authority, + staker, + withdrawer, +}: { + provider: ExtendedProvider + stakeAccount: PublicKey + authority: Keypair + staker?: PublicKey + withdrawer?: PublicKey +}) { + const ixes: Transaction[] = [] + if (staker) { + const ix = StakeProgram.authorize({ + stakePubkey: stakeAccount, + authorizedPubkey: authority.publicKey, + newAuthorizedPubkey: staker, + stakeAuthorizationType: StakeAuthorizationLayout.Staker, + custodianPubkey: undefined, + }) + ixes.push(ix) + } + if (withdrawer) { + const ix = StakeProgram.authorize({ + stakePubkey: stakeAccount, + authorizedPubkey: authority.publicKey, + newAuthorizedPubkey: withdrawer, + stakeAuthorizationType: StakeAuthorizationLayout.Withdrawer, + custodianPubkey: undefined, + }) + ixes.push(ix) + } + await provider.sendIx([authority], ...ixes) +} + +type DelegatedStakeAccount = { + stakeAccount: PublicKey + voteAccount: PublicKey + staker: Keypair + withdrawer: Keypair +} + +export async function delegatedStakeAccount({ + provider, + voteAccountToDelegate, + lockup, + lamports, + rentExemptVote, + staker = Keypair.generate(), + withdrawer = Keypair.generate(), +}: { + provider: ExtendedProvider + voteAccountToDelegate?: PublicKey + lockup?: Lockup + lamports?: number + rentExemptVote?: number + staker?: Keypair + withdrawer?: Keypair +}): Promise { + const stakeAccount = Keypair.generate() + lamports = lamports || (await getRentExemptStake(provider, lamports)) + rentExemptVote = await getRentExemptVote(provider, rentExemptVote) + + voteAccountToDelegate = + voteAccountToDelegate || + (await createVoteAccount(provider, rentExemptVote)).voteAccount + + const createStakeAccountIx = StakeProgram.createAccount({ + fromPubkey: provider.walletPubkey, + stakePubkey: stakeAccount.publicKey, + authorized: new Authorized(staker.publicKey, withdrawer.publicKey), + lamports, + lockup, + }) + // error 0xc on 'Instruction 2' means not enough SOL to delegate the account + // lamports param has to be rentExempt + 1 SOL in new Solana versions + const delegateStakeAccountIx = StakeProgram.delegate({ + stakePubkey: stakeAccount.publicKey, + authorizedPubkey: staker.publicKey, + votePubkey: voteAccountToDelegate, + }) + await provider.sendIx( + [stakeAccount, staker], + createStakeAccountIx, + delegateStakeAccountIx + ) + + return { + stakeAccount: stakeAccount.publicKey, + voteAccount: voteAccountToDelegate, + staker, + withdrawer, + } +} + +export async function nonInitializedStakeAccount( + provider: ExtendedProvider, + rentExempt?: number +): Promise<[PublicKey, Keypair]> { + const accountKeypair = Keypair.generate() + const createSystemAccountIx = SystemProgram.createAccount({ + fromPubkey: provider.walletPubkey, + newAccountPubkey: accountKeypair.publicKey, + lamports: await getRentExemptStake(provider, rentExempt), + space: StakeProgram.space, + programId: StakeProgram.programId, + }) + await provider.sendIx([accountKeypair], createSystemAccountIx) + return [accountKeypair.publicKey, accountKeypair] +} + +type InitializedStakeAccount = { + stakeAccount: PublicKey + staker: Keypair | PublicKey + withdrawer: Keypair | PublicKey +} + +export async function initializedStakeAccount( + provider: ExtendedProvider, + lockup?: Lockup, + rentExempt?: number, + staker: Keypair | PublicKey = Keypair.generate(), + withdrawer: Keypair | PublicKey = Keypair.generate() +): Promise { + const stakeAccount = Keypair.generate() + rentExempt = await getRentExemptStake(provider, rentExempt) + + const ix = StakeProgram.createAccount({ + fromPubkey: provider.walletPubkey, + stakePubkey: stakeAccount.publicKey, + authorized: new Authorized(pubkey(staker), pubkey(withdrawer)), + lamports: rentExempt, + lockup, + }) + await provider.sendIx([stakeAccount], ix) + return { + stakeAccount: stakeAccount.publicKey, + staker, + withdrawer, + } +} diff --git a/packages/validator-bonds-sdk/__tests__/utils/testTransactions.ts b/packages/validator-bonds-sdk/__tests__/utils/testTransactions.ts new file mode 100644 index 00000000..6a4b6fdd --- /dev/null +++ b/packages/validator-bonds-sdk/__tests__/utils/testTransactions.ts @@ -0,0 +1,179 @@ +import { + ValidatorBondsProgram, + initBondInstruction, + initConfigInstruction, +} from '../../src' +import { + Keypair, + LAMPORTS_PER_SOL, + PublicKey, + StakeProgram, + SystemProgram, +} from '@solana/web3.js' +import { pubkey, signer } from './helpers' +import { ExtendedProvider } from './provider' +import { createVoteAccount } from './staking' +import BN from 'bn.js' + +export async function createUserAndFund( + provider: ExtendedProvider, + user: Keypair = Keypair.generate(), + lamports = LAMPORTS_PER_SOL +): Promise { + const instruction = SystemProgram.transfer({ + fromPubkey: provider.walletPubkey, + toPubkey: user.publicKey, + lamports, + }) + try { + await provider.sendIx([], instruction) + } catch (e) { + console.error( + `createUserAndFund: to fund ${user.publicKey.toBase58()} with ${lamports} lamports`, + e + ) + throw e + } + return user +} + +export async function executeWithdraw( + provider: ExtendedProvider, + stakeAccount: PublicKey, + withdrawAuthority: Keypair, + toPubkey?: PublicKey, + lamports?: number +) { + if (lamports === undefined) { + const accountInfo = await provider.connection.getAccountInfo(stakeAccount) + if (accountInfo === null) { + throw new Error( + `executeWithdraw: cannot find the stake account ${stakeAccount.toBase58()}` + ) + } + lamports = accountInfo.lamports + } + const withdrawIx = StakeProgram.withdraw({ + authorizedPubkey: withdrawAuthority.publicKey, + stakePubkey: stakeAccount, + lamports, + toPubkey: toPubkey || provider.walletPubkey, + }) + try { + await provider.sendIx([withdrawAuthority], withdrawIx) + } catch (e) { + console.error( + `executeWithdraw: withdraw ${stakeAccount.toBase58()}, ` + + `withdrawer: ${withdrawAuthority.publicKey.toBase58()}`, + e + ) + throw e + } +} + +export async function executeInitConfigInstruction({ + program, + provider, + epochsToClaimSettlement = Math.floor(Math.random() * 10) + 1, + withdrawLockupEpochs = Math.floor(Math.random() * 10) + 1, + adminAuthority, + operatorAuthority, +}: { + program: ValidatorBondsProgram + provider: ExtendedProvider + epochsToClaimSettlement?: number + withdrawLockupEpochs?: number + adminAuthority?: Keypair + operatorAuthority?: Keypair +}): Promise<{ + configAccount: PublicKey + adminAuthority: Keypair + operatorAuthority: Keypair +}> { + adminAuthority = adminAuthority || Keypair.generate() + operatorAuthority = operatorAuthority || Keypair.generate() + expect(adminAuthority).not.toEqual(operatorAuthority) + + const { configAccount, instruction } = await initConfigInstruction({ + program, + admin: adminAuthority.publicKey, + operator: operatorAuthority.publicKey, + epochsToClaimSettlement, + withdrawLockupEpochs, + }) + const signerConfigAccount = signer(configAccount) + try { + await provider.sendIx([signerConfigAccount], instruction) + } catch (e) { + console.error( + `executeInitConfigInstruction: config account ${pubkey( + configAccount + ).toBase58()}, ` + + `admin: ${adminAuthority.publicKey.toBase58()}, ` + + `operator: ${operatorAuthority.publicKey.toBase58()}`, + e + ) + throw e + } + + return { + configAccount: pubkey(configAccount), + adminAuthority, + operatorAuthority, + } +} + +export async function executeInitBondInstruction( + program: ValidatorBondsProgram, + provider: ExtendedProvider, + config: PublicKey, + bondAuthority?: Keypair, + voteAccount?: PublicKey, + authorizedWithdrawer?: Keypair, + revenueShareHundredthBps: BN | number = Math.floor(Math.random() * 100) + 1 +): Promise<{ + bondAccount: PublicKey + bondAuthority: Keypair + voteAccount: PublicKey + authorizedWithdrawer: Keypair +}> { + bondAuthority = bondAuthority || Keypair.generate() + if (!voteAccount) { + ;({ voteAccount, authorizedWithdrawer } = await createVoteAccount(provider)) + } + if (authorizedWithdrawer === undefined) { + throw new Error('authorizedWithdrawer is undefined') + } + const { instruction, bondAccount } = await initBondInstruction({ + program, + configAccount: config, + bondAuthority: bondAuthority.publicKey, + revenueShareHundredthBps, + validatorVoteAccount: voteAccount, + validatorVoteWithdrawer: authorizedWithdrawer.publicKey, + }) + try { + await provider.sendIx([authorizedWithdrawer], instruction) + } catch (e) { + console.error( + `executeInitBondInstruction: bond account ${pubkey( + bondAccount + ).toBase58()}, ` + + `config: ${pubkey(config).toBase58()}, ` + + `bondAuthority: ${pubkey(bondAuthority.publicKey).toBase58()}, ` + + `voteAccount: ${pubkey(voteAccount).toBase58()}, ` + + `authorizedWithdrawer: ${pubkey( + authorizedWithdrawer.publicKey + ).toBase58()}`, + e + ) + throw e + } + + return { + bondAccount, + bondAuthority, + voteAccount, + authorizedWithdrawer, + } +} diff --git a/packages/validator-bonds-sdk/generated/validator_bonds.ts b/packages/validator-bonds-sdk/generated/validator_bonds.ts index f3924734..a5edecde 100644 --- a/packages/validator-bonds-sdk/generated/validator_bonds.ts +++ b/packages/validator-bonds-sdk/generated/validator_bonds.ts @@ -1322,7 +1322,10 @@ export type ValidatorBonds = { { "name": "stakerAuthority", "isMut": false, - "isSigner": false + "isSigner": false, + "docs": [ + "bonds program authority PDA address: settlement staker or bonds withdrawer" + ] }, { "name": "stakeHistory", @@ -4426,7 +4429,10 @@ export const IDL: ValidatorBonds = { { "name": "stakerAuthority", "isMut": false, - "isSigner": false + "isSigner": false, + "docs": [ + "bonds program authority PDA address: settlement staker or bonds withdrawer" + ] }, { "name": "stakeHistory", diff --git a/packages/validator-bonds-sdk/package.json b/packages/validator-bonds-sdk/package.json index f9e03f50..416aff62 100644 --- a/packages/validator-bonds-sdk/package.json +++ b/packages/validator-bonds-sdk/package.json @@ -26,21 +26,28 @@ "generated" ], "devDependencies": { - "@solana/web3.js": "^1.87.6", "@coral-xyz/anchor": "^0.29.0", - "solana-spl-token-modern": "npm:@solana/spl-token@^0.3.8", - "@marinade.finance/web3js-common": "^2.0.18", - "@marinade.finance/ts-common": "^2.0.18", - "@marinade.finance/anchor-common": "^2.0.18", + "@marinade.finance/anchor-common": "^2.0.20", + "@marinade.finance/marinade-ts-sdk": "^5.0.6", + "@marinade.finance/ts-common": "^2.0.20", + "@marinade.finance/web3js-common": "^2.0.20", + "@solana/buffer-layout": "^4.0.1", + "@solana/web3.js": "^1.87.6", + "anchor-bankrun": "^0.3.0", "solana-bankrun": "^0.2.0", - "anchor-bankrun": "^0.2.0", "bn.js": "^5.2.1", - "jsbi": "^4.3.0" + "borsh": "^0.7.0", + "jsbi": "^4.3.0", + "solana-spl-token-modern": "npm:@solana/spl-token@^0.3.8" }, "peerDependencies": { - "@solana/web3.js": "^1.87.3", "@coral-xyz/anchor": "^0.29.0", + "@solana/web3.js": "^1.87.3", "bn.js": "^5.2.1", + "bs58": "^5.0.0", "jsbi": "^4.3.0" + }, + "engines": { + "node": ">=16" } } diff --git a/packages/validator-bonds-sdk/src/api.ts b/packages/validator-bonds-sdk/src/api.ts index 2fd00f1e..e5ab4446 100644 --- a/packages/validator-bonds-sdk/src/api.ts +++ b/packages/validator-bonds-sdk/src/api.ts @@ -1,6 +1,8 @@ import { ProgramAccount } from '@coral-xyz/anchor' import { PublicKey } from '@solana/web3.js' -import { ValidatorBondsProgram, Config } from './sdk' +import { ValidatorBondsProgram, Config, Bond, WithdrawRequest } from './sdk' +import BN from 'bn.js' +import { bs58 } from '@coral-xyz/anchor/dist/cjs/utils/bytes' // TODO: // - users can create arbitrary stake accounts (even with lockups), sdk must be prepared for that when showing total usable deposits @@ -26,7 +28,8 @@ export async function findConfigs({ filters.push({ memcmp: { bytes: adminAuthority.toBase58(), - offset: 8, // 8 anchor offset + // 8 anchor offset + offset: 8, }, }) } @@ -34,9 +37,108 @@ export async function findConfigs({ filters.push({ memcmp: { bytes: operatorAuthority.toBase58(), - offset: 40, // 8 anchor offset + first data 32B admin pubkey + // 8 anchor offset + first data 32B admin pubkey + offset: 40, }, }) } return await program.account.config.all(filters) } + +export async function getBond( + program: ValidatorBondsProgram, + address: PublicKey +): Promise { + return program.account.bond.fetch(address) +} + +export async function findBonds({ + program, + config, + validatorVoteAccount, + bondAuthority, +}: { + program: ValidatorBondsProgram + config?: PublicKey + validatorVoteAccount?: PublicKey + bondAuthority?: PublicKey +}): Promise[]> { + const filters = [] + if (config) { + filters.push({ + memcmp: { + bytes: config.toBase58(), + // 8 anchor offset + offset: 8, + }, + }) + } + if (validatorVoteAccount) { + filters.push({ + memcmp: { + bytes: validatorVoteAccount.toBase58(), + // 8 anchor offset + first data 32B config pubkey + offset: 40, + }, + }) + } + if (bondAuthority) { + filters.push({ + memcmp: { + bytes: bondAuthority.toBase58(), + // 8 anchor offset + 32B config pubkey + 32B validator vote pubkey + offset: 72, + }, + }) + } + return await program.account.bond.all(filters) +} + +export async function getWithdrawRequest( + program: ValidatorBondsProgram, + address: PublicKey +): Promise { + return program.account.withdrawRequest.fetch(address) +} + +export async function findWithdrawRequests({ + program, + validatorVoteAccount, + bond, + epoch, +}: { + program: ValidatorBondsProgram + validatorVoteAccount?: PublicKey + bond?: PublicKey + epoch?: number | BN +}): Promise[]> { + const filters = [] + if (validatorVoteAccount) { + filters.push({ + memcmp: { + bytes: validatorVoteAccount.toBase58(), + // 8 anchor offset + offset: 8, + }, + }) + } + if (bond) { + filters.push({ + memcmp: { + bytes: bond.toBase58(), + // 8 anchor offset + 32B validator vote pubkey + offset: 40, + }, + }) + } + if (epoch) { + filters.push({ + memcmp: { + bytes: bs58.encode(new BN(epoch).toArray('le', 8)), + // 8 anchor offset + 32B validator vote pubkey + 32B bond pubkey + offset: 72, + }, + }) + } + return await program.account.withdrawRequest.all(filters) +} diff --git a/packages/validator-bonds-sdk/src/index.ts b/packages/validator-bonds-sdk/src/index.ts index b1bf1100..e8c3f6ff 100644 --- a/packages/validator-bonds-sdk/src/index.ts +++ b/packages/validator-bonds-sdk/src/index.ts @@ -1,3 +1,5 @@ export * from './sdk' export * from './api' -export * from './initConfig' +export * from './instructions' +export * from './orchestrators' +export * from './stakeAccount' diff --git a/packages/validator-bonds-sdk/src/instructions/cancelWithdrawRequest.ts b/packages/validator-bonds-sdk/src/instructions/cancelWithdrawRequest.ts new file mode 100644 index 00000000..c44588d0 --- /dev/null +++ b/packages/validator-bonds-sdk/src/instructions/cancelWithdrawRequest.ts @@ -0,0 +1,90 @@ +import { + Keypair, + PublicKey, + Signer, + TransactionInstruction, +} from '@solana/web3.js' +import { + ValidatorBondsProgram, + WithdrawRequest, + bondAddress, + withdrawRequestAddress, +} from '../sdk' +import { walletPubkey } from '../utils' +import { getWithdrawRequest } from '../api' + +export async function cancelWithdrawRequestInstruction({ + program, + withdrawRequestAccount, + bondAccount, + configAccount, + validatorVoteAccount, + authority = walletPubkey(program), + rentCollector = walletPubkey(program), +}: { + program: ValidatorBondsProgram + withdrawRequestAccount?: PublicKey + bondAccount?: PublicKey + configAccount?: PublicKey + validatorVoteAccount: PublicKey + authority?: PublicKey | Keypair | Signer // signer + rentCollector?: PublicKey +}): Promise<{ + instruction: TransactionInstruction +}> { + if ( + configAccount !== undefined && + validatorVoteAccount !== undefined && + bondAccount === undefined + ) { + bondAccount = bondAddress( + configAccount, + validatorVoteAccount, + program.programId + )[0] + } + let withdrawRequestData: WithdrawRequest | undefined + if ( + withdrawRequestAccount !== undefined && + (bondAccount === undefined || validatorVoteAccount === undefined) + ) { + withdrawRequestData = await getWithdrawRequest( + program, + withdrawRequestAccount + ) + bondAccount = bondAccount || withdrawRequestData.bond + validatorVoteAccount = + validatorVoteAccount || withdrawRequestData.validatorVoteAccount + } + if (bondAccount !== undefined && withdrawRequestAccount === undefined) { + withdrawRequestAccount = withdrawRequestAddress( + bondAccount, + program.programId + )[0] + } + if (bondAccount !== undefined && validatorVoteAccount === undefined) { + const bondData = await program.account.bond.fetch(bondAccount) + validatorVoteAccount = bondData.validatorVoteAccount + } + authority = authority instanceof PublicKey ? authority : authority.publicKey + + if (withdrawRequestAccount === undefined) { + throw new Error( + 'withdrawRequestAccount not provided and could not be derived from other parameters' + ) + } + + const instruction = await program.methods + .cancelWithdrawRequest() + .accounts({ + bond: bondAccount, + validatorVoteAccount, + authority, + withdrawRequest: withdrawRequestAccount, + rentCollector, + }) + .instruction() + return { + instruction, + } +} diff --git a/packages/validator-bonds-sdk/src/instructions/claimWithdrawRequest.ts b/packages/validator-bonds-sdk/src/instructions/claimWithdrawRequest.ts new file mode 100644 index 00000000..5934336c --- /dev/null +++ b/packages/validator-bonds-sdk/src/instructions/claimWithdrawRequest.ts @@ -0,0 +1,122 @@ +import { + Keypair, + PublicKey, + SYSVAR_STAKE_HISTORY_PUBKEY, + Signer, + StakeProgram, + TransactionInstruction, +} from '@solana/web3.js' +import { + ValidatorBondsProgram, + WithdrawRequest, + bondAddress, + withdrawRequestAddress, +} from '../sdk' +import { getWithdrawRequest } from '../api' +import { getVoteAccount } from '../stakeAccount' +import { walletPubkey } from '../utils' + +export async function claimWithdrawRequestInstruction({ + program, + withdrawRequestAccount, + bondAccount, + configAccount, + validatorVoteAccount, + stakeAccount, + splitStakeRentPayer = walletPubkey(program), + withdrawer, +}: { + program: ValidatorBondsProgram + withdrawRequestAccount?: PublicKey + bondAccount?: PublicKey + configAccount?: PublicKey + validatorVoteAccount?: PublicKey + stakeAccount: PublicKey + splitStakeRentPayer?: PublicKey | Keypair | Signer // signer + withdrawer?: PublicKey +}): Promise<{ + instruction: TransactionInstruction + splitStakeAccount: Keypair +}> { + if ( + configAccount !== undefined && + validatorVoteAccount !== undefined && + bondAccount === undefined + ) { + bondAccount = bondAddress( + configAccount, + validatorVoteAccount, + program.programId + )[0] + } + let withdrawRequestData: WithdrawRequest | undefined + if ( + withdrawRequestAccount !== undefined && + (bondAccount === undefined || validatorVoteAccount === undefined) + ) { + withdrawRequestData = await getWithdrawRequest( + program, + withdrawRequestAccount + ) + bondAccount = bondAccount || withdrawRequestData.bond + validatorVoteAccount = + validatorVoteAccount || withdrawRequestData.validatorVoteAccount + } + if (bondAccount !== undefined && withdrawRequestAccount === undefined) { + withdrawRequestAccount = withdrawRequestAddress( + bondAccount, + program.programId + )[0] + } + if ( + bondAccount !== undefined && + (validatorVoteAccount === undefined || configAccount === undefined) + ) { + const bondData = await program.account.bond.fetch(bondAccount) + validatorVoteAccount = validatorVoteAccount || bondData.validatorVoteAccount + configAccount = configAccount || bondData.config + } + + if (withdrawRequestAccount === undefined) { + throw new Error( + 'withdrawRequestAccount not provided and could not be derived from other parameters' + ) + } + + if (withdrawer === undefined) { + withdrawRequestData = + withdrawRequestData || + (await getWithdrawRequest(program, withdrawRequestAccount)) + const voteAccountData = await getVoteAccount( + program, + withdrawRequestData.validatorVoteAccount + ) + withdrawer = voteAccountData.account.data.authorizedWithdrawer + } + + splitStakeRentPayer = + splitStakeRentPayer instanceof PublicKey + ? splitStakeRentPayer + : splitStakeRentPayer.publicKey + const splitStakeAccount = Keypair.generate() + + const instruction = await program.methods + .claimWithdrawRequest() + .accounts({ + config: configAccount, + bond: bondAccount, + validatorVoteAccount, + withdrawRequest: withdrawRequestAccount, + stakeAccount, + withdrawer, + splitStakeAccount: splitStakeAccount.publicKey, + splitStakeRentPayer, + stakeHistory: SYSVAR_STAKE_HISTORY_PUBKEY, + stakeProgram: StakeProgram.programId, + }) + .instruction() + return { + instruction, + splitStakeAccount, + } +} diff --git a/packages/validator-bonds-sdk/src/instructions/configureBond.ts b/packages/validator-bonds-sdk/src/instructions/configureBond.ts new file mode 100644 index 00000000..0253d820 --- /dev/null +++ b/packages/validator-bonds-sdk/src/instructions/configureBond.ts @@ -0,0 +1,69 @@ +import { + Keypair, + PublicKey, + Signer, + TransactionInstruction, +} from '@solana/web3.js' +import { CONFIG_ADDRESS, ValidatorBondsProgram } from '../sdk' +import { checkAndGetBondAddress, walletPubkey } from '../utils' +import BN from 'bn.js' +import { getBond } from '../api' + +export async function configureBondInstruction({ + program, + bondAccount, + configAccount = CONFIG_ADDRESS, + validatorVoteAccount, + authority = walletPubkey(program), + newBondAuthority, + newRevenueShareHundredthBps, +}: { + program: ValidatorBondsProgram + bondAccount?: PublicKey + configAccount?: PublicKey + validatorVoteAccount?: PublicKey + authority?: PublicKey | Keypair | Signer // signer + newBondAuthority?: PublicKey + newRevenueShareHundredthBps?: BN | number +}): Promise<{ + bondAccount: PublicKey + instruction: TransactionInstruction +}> { + bondAccount = checkAndGetBondAddress( + bondAccount, + configAccount, + validatorVoteAccount, + program.programId + ) + if (validatorVoteAccount === undefined) { + const bondData = await getBond(program, bondAccount) + validatorVoteAccount = bondData.validatorVoteAccount + } + authority = authority instanceof PublicKey ? authority : authority.publicKey + + if (newRevenueShareHundredthBps !== undefined) { + newRevenueShareHundredthBps = + newRevenueShareHundredthBps instanceof BN + ? newRevenueShareHundredthBps.toNumber() + : newRevenueShareHundredthBps + } + + const instruction = await program.methods + .configureBond({ + bondAuthority: newBondAuthority === undefined ? null : newBondAuthority, + revenueShare: + newRevenueShareHundredthBps === undefined + ? null + : { hundredthBps: newRevenueShareHundredthBps }, + }) + .accounts({ + bond: bondAccount, + authority, + validatorVoteAccount, + }) + .instruction() + return { + bondAccount, + instruction, + } +} diff --git a/packages/validator-bonds-sdk/src/instructions/configureConfig.ts b/packages/validator-bonds-sdk/src/instructions/configureConfig.ts new file mode 100644 index 00000000..3d529680 --- /dev/null +++ b/packages/validator-bonds-sdk/src/instructions/configureConfig.ts @@ -0,0 +1,73 @@ +import { + Keypair, + PublicKey, + Signer, + TransactionInstruction, +} from '@solana/web3.js' +import { + CONFIG_ADDRESS, + ConfigureConfigArgs, + ValidatorBondsProgram, +} from '../sdk' +import BN from 'bn.js' +import { getConfig } from '../api' + +export async function configureConfigInstruction({ + program, + configAccount = CONFIG_ADDRESS, + adminAuthority, + newAdmin, + newOperator, + newEpochsToClaimSettlement, + newWithdrawLockupEpochs, + newMinimumStakeLamports, +}: { + program: ValidatorBondsProgram + configAccount?: PublicKey + adminAuthority?: PublicKey | Keypair | Signer // signer + newAdmin?: PublicKey + newOperator?: PublicKey + newEpochsToClaimSettlement?: BN | number + newWithdrawLockupEpochs?: BN | number + newMinimumStakeLamports?: BN | number +}): Promise<{ + instruction: TransactionInstruction +}> { + if (adminAuthority === undefined) { + const configData = await getConfig(program, configAccount) + adminAuthority = configData.adminAuthority + } + adminAuthority = + adminAuthority instanceof PublicKey + ? adminAuthority + : adminAuthority.publicKey + + const args: ConfigureConfigArgs = { + admin: newAdmin || null, + operator: newOperator || null, + epochsToClaimSettlement: newEpochsToClaimSettlement + ? new BN(newEpochsToClaimSettlement) + : null, + withdrawLockupEpochs: newWithdrawLockupEpochs + ? new BN(newWithdrawLockupEpochs) + : null, + minimumStakeLamports: newMinimumStakeLamports + ? new BN(newMinimumStakeLamports) + : null, + } + + if (Object.values(args).every(v => v === null)) { + throw new Error('No new config values provided') + } + + const instruction = await program.methods + .configureConfig(args) + .accounts({ + adminAuthority, + config: configAccount, + }) + .instruction() + return { + instruction, + } +} diff --git a/packages/validator-bonds-sdk/src/instructions/fundBond.ts b/packages/validator-bonds-sdk/src/instructions/fundBond.ts new file mode 100644 index 00000000..27f1a710 --- /dev/null +++ b/packages/validator-bonds-sdk/src/instructions/fundBond.ts @@ -0,0 +1,56 @@ +import { + Keypair, + PublicKey, + SYSVAR_STAKE_HISTORY_PUBKEY, + Signer, + StakeProgram, + TransactionInstruction, +} from '@solana/web3.js' +import { ValidatorBondsProgram } from '../sdk' +import { checkAndGetBondAddress, walletPubkey } from '../utils' +import { getBond } from '../api' + +export async function fundBondInstruction({ + program, + configAccount, + validatorVoteAccount, + bondAccount, + authority = walletPubkey(program), + stakeAccount, +}: { + program: ValidatorBondsProgram + configAccount?: PublicKey + validatorVoteAccount?: PublicKey + bondAccount?: PublicKey + authority?: PublicKey | Keypair | Signer // signer + stakeAccount: PublicKey +}): Promise<{ + instruction: TransactionInstruction +}> { + bondAccount = checkAndGetBondAddress( + bondAccount, + configAccount, + validatorVoteAccount, + program.programId + ) + if (configAccount === undefined) { + const bondData = await getBond(program, bondAccount) + configAccount = bondData.config + } + authority = authority instanceof PublicKey ? authority : authority.publicKey + + const instruction = await program.methods + .fundBond() + .accounts({ + config: configAccount, + bond: bondAccount, + stakeAuthority: authority, + stakeAccount, + stakeHistory: SYSVAR_STAKE_HISTORY_PUBKEY, + stakeProgram: StakeProgram.programId, + }) + .instruction() + return { + instruction, + } +} diff --git a/packages/validator-bonds-sdk/src/instructions/index.ts b/packages/validator-bonds-sdk/src/instructions/index.ts new file mode 100644 index 00000000..34d1a295 --- /dev/null +++ b/packages/validator-bonds-sdk/src/instructions/index.ts @@ -0,0 +1,8 @@ +export * from './cancelWithdrawRequest' +export * from './configureConfig' +export * from './configureBond' +export * from './fundBond' +export * from './initBond' +export * from './initConfig' +export * from './initWithdrawRequest' +export * from './merge' diff --git a/packages/validator-bonds-sdk/src/instructions/initBond.ts b/packages/validator-bonds-sdk/src/instructions/initBond.ts new file mode 100644 index 00000000..e04f7aa6 --- /dev/null +++ b/packages/validator-bonds-sdk/src/instructions/initBond.ts @@ -0,0 +1,64 @@ +import { + Keypair, + PublicKey, + Signer, + TransactionInstruction, +} from '@solana/web3.js' +import { CONFIG_ADDRESS, ValidatorBondsProgram, bondAddress } from '../sdk' +import { walletPubkey } from '../utils' +import BN from 'bn.js' + +export async function initBondInstruction({ + program, + configAccount = CONFIG_ADDRESS, + validatorVoteAccount, + validatorVoteWithdrawer = walletPubkey(program), + bondAuthority = walletPubkey(program), + revenueShareHundredthBps, + rentPayer = walletPubkey(program), +}: { + program: ValidatorBondsProgram + configAccount?: PublicKey + validatorVoteAccount: PublicKey + validatorVoteWithdrawer?: PublicKey | Keypair | Signer // signer + bondAuthority?: PublicKey + revenueShareHundredthBps: BN | number + rentPayer?: PublicKey | Keypair | Signer // signer +}): Promise<{ + instruction: TransactionInstruction + bondAccount: PublicKey +}> { + const authorizedWithdrawer = + validatorVoteWithdrawer instanceof PublicKey + ? validatorVoteWithdrawer + : validatorVoteWithdrawer.publicKey + const renPayerPubkey = + rentPayer instanceof PublicKey ? rentPayer : rentPayer.publicKey + const [bondAccount] = bondAddress( + configAccount, + validatorVoteAccount, + program.programId + ) + const revenueShare = + revenueShareHundredthBps instanceof BN + ? revenueShareHundredthBps.toNumber() + : revenueShareHundredthBps + + const instruction = await program.methods + .initBond({ + bondAuthority, + revenueShare: { hundredthBps: revenueShare }, + }) + .accounts({ + config: configAccount, + bond: bondAccount, + validatorVoteAccount, + authorizedWithdrawer, + rentPayer: renPayerPubkey, + }) + .instruction() + return { + bondAccount, + instruction, + } +} diff --git a/packages/validator-bonds-sdk/src/initConfig.ts b/packages/validator-bonds-sdk/src/instructions/initConfig.ts similarity index 63% rename from packages/validator-bonds-sdk/src/initConfig.ts rename to packages/validator-bonds-sdk/src/instructions/initConfig.ts index 48859e8d..94464d87 100644 --- a/packages/validator-bonds-sdk/src/initConfig.ts +++ b/packages/validator-bonds-sdk/src/instructions/initConfig.ts @@ -1,11 +1,12 @@ import { Keypair, PublicKey, - SystemProgram, + Signer, TransactionInstruction, } from '@solana/web3.js' -import { ValidatorBondsProgram } from './sdk' +import { ValidatorBondsProgram } from '../sdk' import BN from 'bn.js' +import { walletPubkey } from '../utils' /** * Generate instruction to init config root account. @@ -13,8 +14,8 @@ import BN from 'bn.js' * @type {Object} args - Arguments on instruction creation * @param param {ValidatorBondsProgram} args.program - anchor program instance * @param param {PublicKey} args.configAccount - new config account address [SIGNER] (when not provided, it will be generated) - * @param param {PublicKey} args.adminAuthority - admin authority (default: provider wallet address) - * @param param {PublicKey} args.operatorAuthority - operator authority (default: adminAuthority) + * @param param {PublicKey} args.admin - admin authority (default: provider wallet address) + * @param param {PublicKey} args.operator - operator authority (default: adminAuthority) * @param param {PublicKey} args.rentPayer - rent exception payer [SIGNER] (default: provider wallet address) * @param param {PublicKey} args.claimSettlementAfterEpochs - number of epochs after which claim can be settled (default: 0) * @param param {PublicKey} args.withdrawLockupEpochs - number of epochs after which withdraw can be executed (default: 0) @@ -25,41 +26,41 @@ import BN from 'bn.js' export async function initConfigInstruction({ program, configAccount = Keypair.generate(), - adminAuthority = program.provider.publicKey!, - operatorAuthority = adminAuthority, - rentPayer = program.provider.publicKey!, + admin = walletPubkey(program), + operator = admin, + rentPayer = walletPubkey(program), epochsToClaimSettlement = 0, withdrawLockupEpochs = 0, }: { program: ValidatorBondsProgram - configAccount?: PublicKey | Keypair // signer - adminAuthority?: PublicKey - operatorAuthority?: PublicKey - rentPayer?: PublicKey // signer + configAccount?: PublicKey | Keypair | Signer // signer + admin?: PublicKey + operator?: PublicKey + rentPayer?: PublicKey | Keypair | Signer // signer epochsToClaimSettlement?: BN | number withdrawLockupEpochs?: BN | number }): Promise<{ - keypair: Keypair | undefined + configAccount: PublicKey | Keypair | Signer instruction: TransactionInstruction }> { - const configAccountAddress = - configAccount instanceof Keypair ? configAccount.publicKey : configAccount + const configAccountPubkey = + configAccount instanceof PublicKey ? configAccount : configAccount.publicKey const instruction = await program.methods .initConfig({ - adminAuthority, - operatorAuthority, + adminAuthority: admin, + operatorAuthority: operator, epochsToClaimSettlement: new BN(epochsToClaimSettlement), withdrawLockupEpochs: new BN(withdrawLockupEpochs), }) - .accountsStrict({ - config: configAccountAddress, - rentPayer, - systemProgram: SystemProgram.programId, + .accounts({ + config: configAccountPubkey, + rentPayer: + rentPayer instanceof PublicKey ? rentPayer : rentPayer.publicKey, }) .instruction() return { - keypair: configAccount instanceof Keypair ? configAccount : undefined, + configAccount, instruction, } } diff --git a/packages/validator-bonds-sdk/src/instructions/initWithdrawRequest.ts b/packages/validator-bonds-sdk/src/instructions/initWithdrawRequest.ts new file mode 100644 index 00000000..d72cdcc9 --- /dev/null +++ b/packages/validator-bonds-sdk/src/instructions/initWithdrawRequest.ts @@ -0,0 +1,70 @@ +import { + Keypair, + PublicKey, + Signer, + TransactionInstruction, +} from '@solana/web3.js' +import { ValidatorBondsProgram, withdrawRequestAddress } from '../sdk' +import { checkAndGetBondAddress, walletPubkey } from '../utils' +import BN from 'bn.js' + +export async function initWithdrawRequestInstruction({ + program, + bondAccount, + configAccount, + validatorVoteAccount, + authority = walletPubkey(program), + rentPayer = walletPubkey(program), + amount, +}: { + program: ValidatorBondsProgram + bondAccount?: PublicKey + configAccount?: PublicKey + validatorVoteAccount?: PublicKey + authority?: PublicKey | Keypair | Signer // signer + rentPayer?: PublicKey | Keypair | Signer // signer + amount: BN | number +}): Promise<{ + instruction: TransactionInstruction + withdrawRequest: PublicKey +}> { + bondAccount = checkAndGetBondAddress( + bondAccount, + configAccount, + validatorVoteAccount, + program.programId + ) + if (!validatorVoteAccount) { + const bondData = await program.account.bond.fetch(bondAccount) + validatorVoteAccount = bondData.validatorVoteAccount + } + if (!configAccount) { + const bondData = await program.account.bond.fetch(bondAccount) + configAccount = bondData.config + } + + authority = authority instanceof PublicKey ? authority : authority.publicKey + rentPayer = rentPayer instanceof PublicKey ? rentPayer : rentPayer.publicKey + const [withdrawRequest] = withdrawRequestAddress( + bondAccount, + program.programId + ) + + const instruction = await program.methods + .initWithdrawRequest({ + amount: new BN(amount), + }) + .accounts({ + config: configAccount, + bond: bondAccount, + validatorVoteAccount, + withdrawRequest, + authority, + rentPayer, + }) + .instruction() + return { + withdrawRequest, + instruction, + } +} diff --git a/packages/validator-bonds-sdk/src/instructions/merge.ts b/packages/validator-bonds-sdk/src/instructions/merge.ts new file mode 100644 index 00000000..6ad45d75 --- /dev/null +++ b/packages/validator-bonds-sdk/src/instructions/merge.ts @@ -0,0 +1,52 @@ +import { + PublicKey, + SYSVAR_STAKE_HISTORY_PUBKEY, + StakeProgram, + TransactionInstruction, +} from '@solana/web3.js' +import { + CONFIG_ADDRESS, + ValidatorBondsProgram, + withdrawerAuthority, +} from '../sdk' + +export async function mergeInstruction({ + program, + configAccount = CONFIG_ADDRESS, + sourceStakeAccount, + destinationStakeAccount, + settlementAccount = PublicKey.default, +}: { + program: ValidatorBondsProgram + configAccount?: PublicKey + sourceStakeAccount: PublicKey + destinationStakeAccount: PublicKey + settlementAccount?: PublicKey +}): Promise<{ + instruction: TransactionInstruction +}> { + // TODO: settlement management + // idea of the merge instruction is to merge two stake accounts owned by bonds program + // stake account staker authority can be either bond managed or settlement managed + // it would be good to check settlements automatically by searching all settlements of the bond and validator + // and make sdk to find the right settlement to use when the settlement pubkey is not provided as param + const [bondsWithdrawerAuthority] = withdrawerAuthority(configAccount) + + const instruction = await program.methods + .merge({ + settlement: settlementAccount, + }) + .accounts({ + config: configAccount, + sourceStake: sourceStakeAccount, + destinationStake: destinationStakeAccount, + stakerAuthority: bondsWithdrawerAuthority, + stakeHistory: SYSVAR_STAKE_HISTORY_PUBKEY, + stakeProgram: StakeProgram.programId, + }) + .instruction() + + return { + instruction, + } +} diff --git a/packages/validator-bonds-sdk/src/orchestrators/index.ts b/packages/validator-bonds-sdk/src/orchestrators/index.ts new file mode 100644 index 00000000..27f6d8b8 --- /dev/null +++ b/packages/validator-bonds-sdk/src/orchestrators/index.ts @@ -0,0 +1 @@ +export * from './orchestrateWithdrawDeposit' diff --git a/packages/validator-bonds-sdk/src/orchestrators/orchestrateWithdrawDeposit.ts b/packages/validator-bonds-sdk/src/orchestrators/orchestrateWithdrawDeposit.ts new file mode 100644 index 00000000..411d8202 --- /dev/null +++ b/packages/validator-bonds-sdk/src/orchestrators/orchestrateWithdrawDeposit.ts @@ -0,0 +1,156 @@ +import { + Keypair, + PublicKey, + Signer, + TransactionInstruction, +} from '@solana/web3.js' +import { + ProgramAccountInfo, + ValidatorBondsProgram, + WithdrawRequest, + withdrawRequestAddress, +} from '../sdk' +import { getBond, getWithdrawRequest } from '../api' +import assert from 'assert' +import { + StakeAccountParsed, + findStakeAccountAccount, + getVoteAccount, +} from '../stakeAccount' +import BN from 'bn.js' +import { mergeInstruction } from '../instructions/merge' +import { claimWithdrawRequestInstruction } from '../instructions/claimWithdrawRequest' +import { walletPubkey } from '../utils' + +/** + * Returning the instructions for withdrawing the deposit (on top of the withdraw request) + * while trying to find right accounts when available and merge them together. + */ +export async function orchestrateWithdrawDeposit({ + program, + withdrawRequestAccount, + bondAccount, + splitStakeRentPayer = walletPubkey(program), +}: { + program: ValidatorBondsProgram + withdrawRequestAccount?: PublicKey + bondAccount?: PublicKey + splitStakeRentPayer?: PublicKey | Keypair | Signer // signer +}): Promise<{ + instructions: TransactionInstruction[] + splitStakeAccount: Keypair | undefined // signer +}> { + let withdrawRequestData: WithdrawRequest | undefined + if (bondAccount === undefined && withdrawRequestAccount === undefined) { + throw new Error( + 'bondAccount and withdrawRequestAccount not provided, at least one has to be provided' + ) + } else if ( + bondAccount === undefined && + withdrawRequestAccount !== undefined + ) { + withdrawRequestData = await getWithdrawRequest( + program, + withdrawRequestAccount + ) + bondAccount = withdrawRequestData.bond + } else if ( + bondAccount !== undefined && + withdrawRequestAccount === undefined + ) { + withdrawRequestAccount = withdrawRequestAddress( + bondAccount, + program.programId + )[0] + } + assert( + withdrawRequestAccount !== undefined, + 'this should not happen; withdrawRequestAccount is undefined' + ) + assert( + bondAccount !== undefined, + 'this should not happen; bondAccount is undefined' + ) + + withdrawRequestData = + withdrawRequestData || + (await getWithdrawRequest(program, withdrawRequestAccount)) + const bondData = await getBond(program, bondAccount) + const voteAccountData = await getVoteAccount( + program, + withdrawRequestData.validatorVoteAccount + ) + const withdrawer = voteAccountData.account.data.authorizedWithdrawer + const configAccount = bondData.config + + let amountToWithdraw = withdrawRequestData.requestedAmount.sub( + withdrawRequestData.withdrawnAmount + ) + amountToWithdraw = + amountToWithdraw <= new BN(0) ? new BN(0) : amountToWithdraw + const stakeAccountsToWithdraw = ( + await findStakeAccountAccount({ + connection: program, + staker: withdrawer, + withdrawer, + }) + ) + .sort((x, y) => + x.account.lamports > y.account.lamports + ? 1 + : x.account.lamports < y.account.lamports + ? -1 + : 0 + ) + .reduce<[BN, ProgramAccountInfo[]]>( + (acc, accountInfo) => { + if (acc[0] < amountToWithdraw) { + acc[0].add(new BN(accountInfo.account.lamports)) + acc[1].push(accountInfo) + } + return acc + }, + [new BN(0), []] + ) + + const instructions: TransactionInstruction[] = [] + let splitStakeAccount: Keypair | undefined = undefined + + // there are some stake accounts to withdraw from + if (stakeAccountsToWithdraw[1].length > 0) { + const destinationStakeAccount = stakeAccountsToWithdraw[1][0].publicKey + // going through from the second item that we want to merge all to the first one + for ( + let mergeIndex = 1; + mergeIndex < stakeAccountsToWithdraw.length; + mergeIndex++ + ) { + const sourceStakeAccount = + stakeAccountsToWithdraw[1][mergeIndex].publicKey + const mergeIx = await mergeInstruction({ + program, + configAccount, + sourceStakeAccount, + destinationStakeAccount, + }) + instructions.push(mergeIx.instruction) + } + const withdrawDeposit = await claimWithdrawRequestInstruction({ + program, + configAccount, + withdrawRequestAccount, + bondAccount, + stakeAccount: destinationStakeAccount, + validatorVoteAccount: withdrawRequestData.validatorVoteAccount, + splitStakeRentPayer, + withdrawer, + }) + instructions.push(withdrawDeposit.instruction) + splitStakeAccount = withdrawDeposit.splitStakeAccount + } + + return { + instructions, + splitStakeAccount, // needed as a signer for the transaction + } +} diff --git a/packages/validator-bonds-sdk/src/sdk.ts b/packages/validator-bonds-sdk/src/sdk.ts index c3f6f343..79c3c31d 100644 --- a/packages/validator-bonds-sdk/src/sdk.ts +++ b/packages/validator-bonds-sdk/src/sdk.ts @@ -8,23 +8,21 @@ import { parseIdlErrors, Provider, Wallet, + IdlTypes, } from '@coral-xyz/anchor' import { Wallet as AnchorWalletInterface } from '@coral-xyz/anchor/dist/cjs/provider' -import { ConfirmOptions, Connection, Keypair, PublicKey } from '@solana/web3.js' - -/** - * Validator Bonds contract Anchor IDL wrapper. - * - * All operations are performed through the program instance. - * - * To get PDA and read account data see ./api.ts - * To execute contract operations see ./with*.ts - */ +import { + AccountInfo, + ConfirmOptions, + Connection, + Keypair, + ParsedAccountData, + PublicKey, +} from '@solana/web3.js' +import BN from 'bn.js' -// TODO: randomly generated, need to grind a better name -// [31,4,248,145,147,37,94,44,125,60,3,95,42,22,31,88,208,50,111,112,185,74,80,202,199,99,65,61,75,177,127,57,38,144,218,174,173,95,124,225,178,105,31,9,171,187,49,43,254,39,37,196,22,237,49,171,154,108,218,48,77,202,198,127] export const CONFIG_ADDRESS = new PublicKey( - '3bYbwEVbfXbmM9evW5bRbFPS9usdp6dtYCYsYtq6NLcE' + 'vbMaRfmTCg92HWGzmd53APkMNpPnGVGZTUHwUJQkXAU' ) export const ValidatorBondsIDL = generated.IDL @@ -38,18 +36,37 @@ export type ValidatorBondsProgram = AnchorProgram // --- ACCOUNTS --- export type Config = IdlAccounts['config'] +export type Bond = IdlAccounts['bond'] +export type SettlementClaim = IdlAccounts['settlementClaim'] +export type Settlement = IdlAccounts['settlement'] +export type WithdrawRequest = IdlAccounts['withdrawRequest'] + +// --- TYPES --- +export type InitConfigArgs = IdlTypes['InitConfigArgs'] +export type ConfigureConfigArgs = + IdlTypes['ConfigureConfigArgs'] +export type InitBondArgs = IdlTypes['InitBondArgs'] +export type HundredthBasisPoint = + IdlTypes['HundredthBasisPoint'] // --- CONSTANTS --- -export const BONDS_AUTHORITY_SEED = new Uint8Array( - JSON.parse( - generated.IDL.constants.find(x => x.name === 'BONDS_AUTHORITY_SEED')!.value - ) -) -export const SETTLEMENT_AUTHORITY_SEED = new Uint8Array( - JSON.parse( - generated.IDL.constants.find(x => x.name === 'SETTLEMENT_AUTHORITY_SEED')! - .value - ) +function seedFromConstants(seedName: string): Uint8Array { + const constant = generated.IDL.constants.find(x => x.name === seedName) + if (constant === undefined) { + throw new Error( + 'SDK initialization failure. Validator bonds IDL does not define constant ' + + constant + ) + } + return new Uint8Array(JSON.parse(constant.value)) +} +export const BOND_SEED = seedFromConstants('BOND_SEED') +export const SETTLEMENT_SEED = seedFromConstants('SETTLEMENT_SEED') +export const WITHDRAW_REQUEST_SEED = seedFromConstants('WITHDRAW_REQUEST_SEED') +export const SETTLEMENT_CLAIM_SEED = seedFromConstants('SETTLEMENT_CLAIM_SEED') +export const BONDS_AUTHORITY_SEED = seedFromConstants('BONDS_AUTHORITY_SEED') +export const SETTLEMENT_AUTHORITY_SEED = seedFromConstants( + 'SETTLEMENT_AUTHORITY_SEED' ) // --- EVENTS --- @@ -57,8 +74,68 @@ export const INIT_CONFIG_EVENT = 'InitConfigEvent' export type InitConfigEvent = IdlEvents[typeof INIT_CONFIG_EVENT] +export const INIT_BOND_EVENT = 'InitBondEvent' +export type InitBondEvent = IdlEvents[typeof INIT_BOND_EVENT] + +export const CONFIGURE_BOND_EVENT = 'ConfigureBondEvent' +export type ConfigureBondEvent = + IdlEvents[typeof CONFIGURE_BOND_EVENT] + +export const CLOSE_BOND_EVENT = 'CloseBondEvent' +export type CloseBondEvent = IdlEvents[typeof CLOSE_BOND_EVENT] + +export const FUND_BOND_EVENT = 'FundBondEvent' +export type FundBondEvent = IdlEvents[typeof FUND_BOND_EVENT] + +export const CONFIGURE_CONFIG_EVENT = 'ConfigureConfigEvent' +export type ConfigureConfigEvent = + IdlEvents[typeof CONFIGURE_CONFIG_EVENT] + +export const CLAIM_SETTLEMENT_EVENT = 'ClaimSettlementEvent' +export type ClaimSettlementEvent = + IdlEvents[typeof CLAIM_SETTLEMENT_EVENT] + +export const CLOSE_SETTLEMENT_CLAIM_EVENT = 'CloseSettlementClaimEvent' +export type CloseSettlementClaimEvent = + IdlEvents[typeof CLOSE_SETTLEMENT_CLAIM_EVENT] + +export const INIT_SETTLEMENT_EVENT = 'InitSettlementEvent' +export type InitSettlementEvent = + IdlEvents[typeof INIT_SETTLEMENT_EVENT] + +export const CLOSE_SETTLEMENT_EVENT = 'CloseSettlementEvent' +export type CloseSettlementEvent = + IdlEvents[typeof CLOSE_SETTLEMENT_EVENT] + +export const MERGE_EVENT = 'MergeEvent' +export type MergeEvent = IdlEvents[typeof MERGE_EVENT] + +export const RESET_EVENT = 'ResetEvent' +export type ResetEvent = IdlEvents[typeof RESET_EVENT] + +export const CANCEL_WITHDRAW_REQUEST_EVENT = 'CancelWithdrawRequestEvent' +export type CancelWithdrawRequestEvent = + IdlEvents[typeof CANCEL_WITHDRAW_REQUEST_EVENT] + +export const CLAIM_WITHDRAW_REQUEST_EVENT = 'ClaimWithdrawRequestEvent' +export type ClaimWithdrawRequestEvent = + IdlEvents[typeof CLAIM_WITHDRAW_REQUEST_EVENT] + export const Errors = parseIdlErrors(generated.IDL) +export type ProgramAccountInfo = { + publicKey: PublicKey + account: AccountInfo +} + +export function programAccountInfo( + publicKey: PublicKey, + account: AccountInfo, + data: T +): ProgramAccountInfo { + return { publicKey, account: { ...account, data } } +} + /** * Creating Anchor program instance of the Validator Bonds contract. * It takes a Provider instance or a Connection and a Wallet. @@ -105,7 +182,18 @@ export function getProgram({ return new Program(generated.IDL, programId, provider) } -export function findBondsWithdrawerAuthority( +export function bondAddress( + config: PublicKey, + voteAccount: PublicKey, + validatorBondsProgramId: PublicKey = VALIDATOR_BONDS_PROGRAM_ID +): [PublicKey, number] { + return PublicKey.findProgramAddressSync( + [BOND_SEED, config.toBytes(), voteAccount.toBytes()], + validatorBondsProgramId + ) +} + +export function withdrawerAuthority( config: PublicKey, validatorBondsProgramId: PublicKey = VALIDATOR_BONDS_PROGRAM_ID ): [PublicKey, number] { @@ -114,3 +202,55 @@ export function findBondsWithdrawerAuthority( validatorBondsProgramId ) } + +export function settlementAddress( + bond: PublicKey, + merkleRoot: Uint8Array, + validatorBondsProgramId: PublicKey = VALIDATOR_BONDS_PROGRAM_ID +): [PublicKey, number] { + return PublicKey.findProgramAddressSync( + [SETTLEMENT_SEED, bond.toBytes(), merkleRoot], + validatorBondsProgramId + ) +} + +export function settlementAuthority( + settlement: PublicKey, + validatorBondsProgramId: PublicKey = VALIDATOR_BONDS_PROGRAM_ID +): [PublicKey, number] { + return PublicKey.findProgramAddressSync( + [SETTLEMENT_AUTHORITY_SEED, settlement.toBytes()], + validatorBondsProgramId + ) +} + +export function settlementClaimAddress( + settlement: PublicKey, + stakeAuthority: PublicKey, + withdrawAuthority: PublicKey, + voteAccount: PublicKey, + claim: BN, + validatorBondsProgramId: PublicKey = VALIDATOR_BONDS_PROGRAM_ID +): [PublicKey, number] { + return PublicKey.findProgramAddressSync( + [ + SETTLEMENT_CLAIM_SEED, + settlement.toBytes(), + stakeAuthority.toBytes(), + withdrawAuthority.toBytes(), + voteAccount.toBytes(), + claim.toArrayLike(Buffer, 'le', 8), + ], + validatorBondsProgramId + ) +} + +export function withdrawRequestAddress( + bond: PublicKey, + validatorBondsProgramId: PublicKey = VALIDATOR_BONDS_PROGRAM_ID +): [PublicKey, number] { + return PublicKey.findProgramAddressSync( + [WITHDRAW_REQUEST_SEED, bond.toBytes()], + validatorBondsProgramId + ) +} diff --git a/packages/validator-bonds-sdk/src/stakeAccount.ts b/packages/validator-bonds-sdk/src/stakeAccount.ts new file mode 100644 index 00000000..2c97be32 --- /dev/null +++ b/packages/validator-bonds-sdk/src/stakeAccount.ts @@ -0,0 +1,224 @@ +import { Idl, Program, Provider } from '@coral-xyz/anchor' +import { + AccountInfo, + Connection, + GetProgramAccountsFilter, + ParsedAccountData, + PublicKey, + StakeProgram, + VoteAccount, +} from '@solana/web3.js' +import assert from 'assert' +import BN from 'bn.js' +import { ProgramAccountInfo, programAccountInfo } from './sdk' + +export type StakeAccountParsed = { + address: PublicKey + withdrawer: PublicKey | null + staker: PublicKey | null + voter: PublicKey | null + activationEpoch: BN | null + deactivationEpoch: BN | null + isCoolingDown: boolean + isLockedUp: boolean + balanceLamports: BN | null + stakedLamports: BN | null + currentEpoch: number + currentTimestamp: number +} + +function getConnection( + providerOrConnection: Provider | Connection | Program +): Connection { + const connection = + providerOrConnection instanceof Program + ? providerOrConnection.provider + : providerOrConnection + return connection instanceof Connection ? connection : connection.connection +} + +async function parseStakeAccountData( + connection: Connection, + address: PublicKey, + stakeAccountInfo: AccountInfo, + currentEpoch?: number +): Promise { + const parsedData = stakeAccountInfo.data.parsed + const activationEpoch = bnOrNull( + parsedData?.info?.stake?.delegation?.activationEpoch ?? null + ) + const deactivationEpoch = bnOrNull( + parsedData?.info?.stake?.delegation?.deactivationEpoch ?? null + ) + const lockup = parsedData?.info?.meta?.lockup + const balanceLamports = bnOrNull(stakeAccountInfo.lamports) + const stakedLamports = bnOrNull( + parsedData?.info?.stake?.delegation.stake ?? null + ) + if (currentEpoch === undefined) { + ;({ epoch: currentEpoch } = await connection.getEpochInfo()) + } + const currentTimestamp = Date.now() / 1000 + + return { + address: address, + withdrawer: pubkeyOrNull(parsedData?.info?.meta?.authorized?.withdrawer), + staker: pubkeyOrNull(parsedData?.info?.meta?.authorized?.staker), + voter: pubkeyOrNull(parsedData?.info?.stake?.delegation?.voter), + + activationEpoch, + deactivationEpoch, + isCoolingDown: deactivationEpoch ? !deactivationEpoch.eq(U64_MAX) : false, + isLockedUp: + lockup?.custodian && + lockup?.custodian !== '' && + (lockup?.epoch > currentEpoch || + lockup?.unixTimestamp > currentTimestamp), + balanceLamports, + stakedLamports, + currentEpoch, + currentTimestamp, + } +} + +function isAccountInfoParsedData( + data: AccountInfo | null +): data is AccountInfo { + if (data === null) { + return false + } + return ( + data.data && + !(data.data instanceof Buffer) && + data.data.parsed !== undefined + ) +} + +export async function getStakeAccount( + connection: Provider | Connection | Program, + address: PublicKey, + currentEpoch?: number +): Promise { + connection = getConnection(connection) + const { value: stakeAccountInfo } = await connection.getParsedAccountInfo( + address + ) + + if (!stakeAccountInfo) { + throw new Error( + `Failed to find the stake account ${address.toBase58()}` + + `at ${connection.rpcEndpoint}` + ) + } + if (!stakeAccountInfo.owner.equals(StakeProgram.programId)) { + throw new Error( + `${address.toBase58()} is not a stake account because owner is ${ + stakeAccountInfo.owner + } at ${connection.rpcEndpoint}` + ) + } + if (!isAccountInfoParsedData(stakeAccountInfo)) { + throw new Error( + `Failed to parse the stake account ${address.toBase58()} data` + + `at ${connection.rpcEndpoint}` + ) + } + + return await parseStakeAccountData( + connection, + address, + stakeAccountInfo, + currentEpoch + ) +} + +const STAKER_OFFSET = 12 +const WITHDRAWER_OFFSET = 44 + +export async function findStakeAccountAccount({ + connection, + staker, + withdrawer, +}: { + connection: Provider | Connection | Program + staker?: PublicKey + withdrawer?: PublicKey +}): Promise[]> { + const innerConnection = getConnection(connection) + + const filters: GetProgramAccountsFilter[] = [] + if (staker) { + filters.push({ + memcmp: { + offset: STAKER_OFFSET, + bytes: staker.toBase58(), + }, + }) + } + if (withdrawer) { + filters.push({ + memcmp: { + offset: WITHDRAWER_OFFSET, + bytes: withdrawer.toBase58(), + }, + }) + } + + const parsedStakeAccounts = await innerConnection.getParsedProgramAccounts( + StakeProgram.programId, + { + filters, + } + ) + + const parsedPromises = parsedStakeAccounts + .filter(({ pubkey, account }) => { + if (!isAccountInfoParsedData(account)) { + console.error( + `Failed to parse the stake account ${pubkey.toBase58()} data` + + `at ${innerConnection.rpcEndpoint}` + ) + return false + } + return true + }) + .map(async ({ pubkey, account }) => { + assert(isAccountInfoParsedData(account), 'already filtered out') + return programAccountInfo( + pubkey, + account, + await parseStakeAccountData(innerConnection, pubkey, account) + ) + }) + return Promise.all(parsedPromises) +} + +export async function getVoteAccount( + providerOrConnection: Provider | Connection | Program, + address: PublicKey +): Promise> { + const connection = getConnection(providerOrConnection) + const voteAccountInfo = await connection.getAccountInfo(address) + if (voteAccountInfo === null) { + throw new Error( + `Vote account ${address.toBase58()} not found at endpoint ` + + `${connection.rpcEndpoint}` + ) + } + const voteAccountData = VoteAccount.fromAccountData(voteAccountInfo.data) + return programAccountInfo(address, voteAccountInfo, voteAccountData) +} + +const U64_MAX = new BN('ffffffffffffffff', 16) + +function pubkeyOrNull( + value?: ConstructorParameters[0] | null +): PublicKey | null { + return value === null || value === undefined ? null : new PublicKey(value) +} + +function bnOrNull( + value?: ConstructorParameters[0] | null +): BN | null { + return value === null || value === undefined ? null : new BN(value) +} diff --git a/packages/validator-bonds-sdk/src/utils.ts b/packages/validator-bonds-sdk/src/utils.ts new file mode 100644 index 00000000..f673559b --- /dev/null +++ b/packages/validator-bonds-sdk/src/utils.ts @@ -0,0 +1,39 @@ +import { Program, Idl } from '@coral-xyz/anchor' +import { PublicKey } from '@solana/web3.js' +import { bondAddress as sdkBondAddress } from './sdk' + +export function walletPubkey(program: Program) { + const pubkey = program.provider.publicKey + if (pubkey === undefined) { + throw new Error( + 'Cannot get wallet pubkey from Anchor Program ' + program.programId + ) + } + return pubkey +} + +export function checkAndGetBondAddress( + bond: PublicKey | undefined, + config: PublicKey | undefined, + voteAccount: PublicKey | undefined, + programId?: PublicKey +): PublicKey { + if (bond !== undefined) { + return bond + } else if (config !== undefined && voteAccount !== undefined) { + return sdkBondAddress(config, voteAccount, programId)[0] + } else { + throw new Error( + 'Either [bondAccount] or [validatorVoteAccount and configAccount] is required' + ) + } +} + +/** + * Convert a number to a bps number which is 10000th of a percent. + * It's 100th of the basic point number. + * 1 HundredthBasisPoint = 0.0001%, 10_000 HundredthBasisPoint = 1%, 1_000_000 HundredthBasisPoint = 100% + */ +export function toHundredsBps(value: number | string): number { + return Math.floor(parseFloat(value.toString()) * 10000) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d4da2a6..f3791281 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: ^29.7.0 version: 29.7.0 '@marinade.finance/jest-utils': - specifier: ^2.0.18 - version: 2.0.18(@jest/globals@29.7.0)(@solana/web3.js@1.87.6)(bn.js@5.2.1)(jest-shell-matchers@1.0.2) + specifier: ^2.0.20 + version: 2.0.20(@jest/globals@29.7.0)(@solana/web3.js@1.87.6)(bn.js@5.2.1)(jest-shell-matchers@1.0.2) '@types/bn.js': specifier: ^5.1.3 version: 5.1.3 @@ -45,17 +45,17 @@ importers: specifier: ^0.29.0 version: 0.29.0 '@marinade.finance/anchor-common': - specifier: ^2.0.18 - version: 2.0.18(@coral-xyz/anchor@0.29.0)(@marinade.finance/ts-common@2.0.18)(@solana/web3.js@1.87.6)(bn.js@5.2.1) + specifier: ^2.0.20 + version: 2.0.20(@coral-xyz/anchor@0.29.0)(@marinade.finance/ts-common@2.0.20)(@solana/web3.js@1.87.6)(bn.js@5.2.1) '@marinade.finance/cli-common': - specifier: ^2.0.18 - version: 2.0.18(@marinade.finance/web3js-common@2.0.18)(@solana/web3.js@1.87.6)(bn.js@5.2.1)(borsh@0.7.0)(bs58@5.0.0)(expand-tilde@2.0.2)(pino@8.16.1)(yaml@2.3.3) + specifier: ^2.0.20 + version: 2.0.20(@marinade.finance/web3js-common@2.0.20)(@solana/web3.js@1.87.6)(bn.js@5.2.1)(borsh@0.7.0)(bs58@5.0.0)(expand-tilde@2.0.2)(pino@8.16.1)(yaml@2.3.3) '@marinade.finance/validator-bonds-sdk': specifier: workspace:* version: link:../validator-bonds-sdk '@marinade.finance/web3js-common': - specifier: ^2.0.18 - version: 2.0.18(@marinade.finance/ts-common@2.0.18)(@solana/web3.js@1.87.6)(bn.js@5.2.1)(borsh@0.7.0)(bs58@5.0.0) + specifier: ^2.0.20 + version: 2.0.20(@marinade.finance/ts-common@2.0.20)(@solana/web3.js@1.87.6)(bn.js@5.2.1)(borsh@0.7.0)(bs58@5.0.0) '@solana/web3.js': specifier: ^1.87.6 version: 1.87.6 @@ -82,32 +82,45 @@ importers: version: 2.3.3 devDependencies: '@marinade.finance/jest-utils': - specifier: ^2.0.18 - version: 2.0.18(@jest/globals@29.7.0)(@solana/web3.js@1.87.6)(bn.js@5.2.1)(jest-shell-matchers@1.0.2) + specifier: ^2.0.20 + version: 2.0.20(@jest/globals@29.7.0)(@solana/web3.js@1.87.6)(bn.js@5.2.1)(jest-shell-matchers@1.0.2) packages/validator-bonds-sdk: + dependencies: + bs58: + specifier: ^5.0.0 + version: 5.0.0 devDependencies: '@coral-xyz/anchor': specifier: ^0.29.0 version: 0.29.0 '@marinade.finance/anchor-common': - specifier: ^2.0.18 - version: 2.0.18(@coral-xyz/anchor@0.29.0)(@marinade.finance/ts-common@2.0.18)(@solana/web3.js@1.87.6)(bn.js@5.2.1) + specifier: ^2.0.20 + version: 2.0.20(@coral-xyz/anchor@0.29.0)(@marinade.finance/ts-common@2.0.20)(@solana/web3.js@1.87.6)(bn.js@5.2.1) + '@marinade.finance/marinade-ts-sdk': + specifier: ^5.0.6 + version: 5.0.6(@solana/web3.js@1.87.6)(bn.js@5.2.1)(jsbi@4.3.0) '@marinade.finance/ts-common': - specifier: ^2.0.18 - version: 2.0.18 + specifier: ^2.0.20 + version: 2.0.20 '@marinade.finance/web3js-common': - specifier: ^2.0.18 - version: 2.0.18(@marinade.finance/ts-common@2.0.18)(@solana/web3.js@1.87.6)(bn.js@5.2.1)(borsh@0.7.0)(bs58@5.0.0) + specifier: ^2.0.20 + version: 2.0.20(@marinade.finance/ts-common@2.0.20)(@solana/web3.js@1.87.6)(bn.js@5.2.1)(borsh@0.7.0)(bs58@5.0.0) + '@solana/buffer-layout': + specifier: ^4.0.1 + version: 4.0.1 '@solana/web3.js': specifier: ^1.87.6 version: 1.87.6 anchor-bankrun: - specifier: ^0.2.0 - version: 0.2.0 + specifier: ^0.3.0 + version: 0.3.0(@coral-xyz/anchor@0.29.0)(@solana/web3.js@1.87.6)(solana-bankrun@0.2.0) bn.js: specifier: ^5.2.1 version: 5.2.1 + borsh: + specifier: ^0.7.0 + version: 0.7.0 jsbi: specifier: ^4.3.0 version: 4.3.0 @@ -468,6 +481,31 @@ packages: resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} dev: true + /@coral-xyz/anchor@0.27.0: + resolution: {integrity: sha512-+P/vPdORawvg3A9Wj02iquxb4T0C5m4P6aZBVYysKl4Amk+r6aMPZkUhilBkD6E4Nuxnoajv3CFykUfkGE0n5g==} + engines: {node: '>=11'} + dependencies: + '@coral-xyz/borsh': 0.27.0(@solana/web3.js@1.87.6) + '@solana/web3.js': 1.87.6 + base64-js: 1.5.1 + bn.js: 5.2.1 + bs58: 4.0.1 + buffer-layout: 1.2.2 + camelcase: 6.3.0 + cross-fetch: 3.1.8 + crypto-hash: 1.3.0 + eventemitter3: 4.0.7 + js-sha256: 0.9.0 + pako: 2.1.0 + snake-case: 3.0.4 + superstruct: 0.15.5 + toml: 3.0.0 + transitivePeerDependencies: + - bufferutil + - encoding + - utf-8-validate + dev: true + /@coral-xyz/anchor@0.28.0: resolution: {integrity: sha512-kQ02Hv2ZqxtWP30WN1d4xxT4QqlOXYDxmEd3k/bbneqhV3X5QMO4LAtoUFs7otxyivOgoqam5Il5qx81FuI4vw==} engines: {node: '>=11'} @@ -516,6 +554,17 @@ packages: - encoding - utf-8-validate + /@coral-xyz/borsh@0.27.0(@solana/web3.js@1.87.6): + resolution: {integrity: sha512-tJKzhLukghTWPLy+n8K8iJKgBq1yLT/AxaNd10yJrX8mI56ao5+OFAKAqW/h0i79KCvb4BK0VGO5ECmmolFz9A==} + engines: {node: '>=10'} + peerDependencies: + '@solana/web3.js': ^1.68.0 + dependencies: + '@solana/web3.js': 1.87.6 + bn.js: 5.2.1 + buffer-layout: 1.2.2 + dev: true + /@coral-xyz/borsh@0.28.0(@solana/web3.js@1.87.6): resolution: {integrity: sha512-/u1VTzw7XooK7rqeD7JLUSwOyRSesPUk0U37BV9zK0axJc1q0nRbKFGFLYCQ16OtdOJTTwGfGp11Lx9B45bRCQ==} engines: {node: '>=10'} @@ -868,24 +917,24 @@ packages: '@jridgewell/sourcemap-codec': 1.4.15 dev: true - /@marinade.finance/anchor-common@2.0.18(@coral-xyz/anchor@0.29.0)(@marinade.finance/ts-common@2.0.18)(@solana/web3.js@1.87.6)(bn.js@5.2.1): - resolution: {integrity: sha512-Yx7MwAVRH++jVe5ygls4+C2pvMttbYTc2QMpVh5i5R6XErrHn8ZbQAAVmFR/5D60n8CEiZtpbM8/qxb6tiS8hA==} + /@marinade.finance/anchor-common@2.0.20(@coral-xyz/anchor@0.29.0)(@marinade.finance/ts-common@2.0.20)(@solana/web3.js@1.87.6)(bn.js@5.2.1): + resolution: {integrity: sha512-o1vw2SevnHE+eRWwyMqwSqFk4RMXPR5ZqAYCAEZBkzcw7QJI0K1BCq3ER1LCMjLPc1gpkO27Dq8vNN6r1cmXbg==} peerDependencies: '@coral-xyz/anchor': ^0.28.0 || 0.29 - '@marinade.finance/ts-common': ^2.0.18 + '@marinade.finance/ts-common': ^2.0.20 '@solana/web3.js': ^1.78.5 bn.js: ^5.2.1 dependencies: '@coral-xyz/anchor': 0.29.0 - '@marinade.finance/ts-common': 2.0.18 + '@marinade.finance/ts-common': 2.0.20 '@solana/web3.js': 1.87.6 bn.js: 5.2.1 - /@marinade.finance/cli-common@2.0.18(@marinade.finance/web3js-common@2.0.18)(@solana/web3.js@1.87.6)(bn.js@5.2.1)(borsh@0.7.0)(bs58@5.0.0)(expand-tilde@2.0.2)(pino@8.16.1)(yaml@2.3.3): - resolution: {integrity: sha512-HZzu50lB9GlUeyu/Ma9sp9qZ8LsHpGwwhNg9pbwKsWoEmQRB4vZW/dfpcRXlomyE4rFczfF0eYIkmxAWQ+Xx1Q==} + /@marinade.finance/cli-common@2.0.20(@marinade.finance/web3js-common@2.0.20)(@solana/web3.js@1.87.6)(bn.js@5.2.1)(borsh@0.7.0)(bs58@5.0.0)(expand-tilde@2.0.2)(pino@8.16.1)(yaml@2.3.3): + resolution: {integrity: sha512-UBnoE3thCu0VxIV9QSpIVzg3SyaPi65NQH8WCzsfjuacnV0/0vLUl253/rvpbe7TPYGoZDiykAQRyXUtYBdWjQ==} engines: {node: '>=16.0.0'} peerDependencies: - '@marinade.finance/web3js-common': ^2.0.18 + '@marinade.finance/web3js-common': ^2.0.20 '@solana/web3.js': ^1.78.5 bn.js: ^5.2.1 borsh: ^0.7.0 @@ -894,7 +943,7 @@ packages: pino: ^8.15.1 yaml: ^2.3.2 dependencies: - '@marinade.finance/web3js-common': 2.0.18(@marinade.finance/ts-common@2.0.18)(@solana/web3.js@1.87.6)(bn.js@5.2.1)(borsh@0.7.0)(bs58@5.0.0) + '@marinade.finance/web3js-common': 2.0.20(@marinade.finance/ts-common@2.0.20)(@solana/web3.js@1.87.6)(bn.js@5.2.1)(borsh@0.7.0)(bs58@5.0.0) '@solana/web3.js': 1.87.6 bn.js: 5.2.1 borsh: 0.7.0 @@ -904,8 +953,27 @@ packages: yaml: 2.3.3 dev: false - /@marinade.finance/jest-utils@2.0.18(@jest/globals@29.7.0)(@solana/web3.js@1.87.6)(bn.js@5.2.1)(jest-shell-matchers@1.0.2): - resolution: {integrity: sha512-l25ehXOaFRyQNZBqS7KIkw0zPO1Rp1WZUi2BCGvo+AL/eVxB3OaT179weFO1S5chZSoQjaJAL/CJVNwjSygmJQ==} + /@marinade.finance/directed-stake-sdk@0.0.4(@solana/web3.js@1.87.6)(bn.js@5.2.1)(jsbi@4.3.0): + resolution: {integrity: sha512-5WTBDRg8hbCHS54wUUhPUuXj9JiQE3Bc1+nmDI3JivQPHu7VsDaPB3w1SHRh0bfARnZQg9oKg8s6jVwNiScCsQ==} + engines: {node: '>10'} + peerDependencies: + '@solana/web3.js': ^1.74.0 + bn.js: ^5.2.1 + jsbi: ^4.3.0 + dependencies: + '@coral-xyz/anchor': 0.27.0 + '@solana/web3.js': 1.87.6 + bn.js: 5.2.1 + bs58: 5.0.0 + jsbi: 4.3.0 + transitivePeerDependencies: + - bufferutil + - encoding + - utf-8-validate + dev: true + + /@marinade.finance/jest-utils@2.0.20(@jest/globals@29.7.0)(@solana/web3.js@1.87.6)(bn.js@5.2.1)(jest-shell-matchers@1.0.2): + resolution: {integrity: sha512-X//GglnqlkWSSng85ak011gHRyzM10Pfg/hKrJUR3SlzUtbUxP4ZbK6NRnAI2hhBPjW/GEY/ete/nndm+qxQgw==} peerDependencies: '@jest/globals': ^29.5.0 '@solana/web3.js': ^1.78.4 @@ -918,20 +986,52 @@ packages: jest-shell-matchers: 1.0.2(jest@29.7.0) dev: true - /@marinade.finance/ts-common@2.0.18: - resolution: {integrity: sha512-ljaJPWm//mK8OFUMwwEIFfj4xmJvQCjlRfmJ06sQnlNaNJf9ef9ZhH682YyGFM09OexkIttlJEc8GEldzKGBhw==} + /@marinade.finance/marinade-ts-sdk@5.0.6(@solana/web3.js@1.87.6)(bn.js@5.2.1)(jsbi@4.3.0): + resolution: {integrity: sha512-oP1SNCey8l/2WJt2h84KSJFiynNGIEsLI6YOslMnvnaNn+PbcBDbxdTUh8IMW+L6bLF3qWYoXryK4TNOzdjR6g==} + engines: {anchor: '>=0.28.0', node: '>=16.0.0'} + dependencies: + '@coral-xyz/anchor': 0.28.0 + '@marinade.finance/directed-stake-sdk': 0.0.4(@solana/web3.js@1.87.6)(bn.js@5.2.1)(jsbi@4.3.0) + '@marinade.finance/native-staking-sdk': 1.0.0 + '@solana/spl-stake-pool': 0.6.5 + '@solana/spl-token-3.x': /@solana/spl-token@0.3.8(@solana/web3.js@1.87.6) + borsh: 0.7.0 + bs58: 5.0.0 + transitivePeerDependencies: + - '@solana/web3.js' + - bn.js + - bufferutil + - encoding + - jsbi + - utf-8-validate + dev: true - /@marinade.finance/web3js-common@2.0.18(@marinade.finance/ts-common@2.0.18)(@solana/web3.js@1.87.6)(bn.js@5.2.1)(borsh@0.7.0)(bs58@5.0.0): - resolution: {integrity: sha512-YE0FYbJ/axEGlVKTwCu526phwCpf4oSquGyg8OAjxAAalbaRV4w02b2LTfDTdEvm8pNPjCCJ7qdNH+QfeRu4fA==} + /@marinade.finance/native-staking-sdk@1.0.0: + resolution: {integrity: sha512-Cj2dy3SH9LAgcFBpGEgRHyF/nK+L7SfNIHkGqAZYRDVgn/h7u8bqKNkYqKXgCWbA5WwPmJzugWWm1DxEt7LyPQ==} + dependencies: + '@solana/spl-memo': 0.2.3(@solana/web3.js@1.87.6) + '@solana/web3.js': 1.87.6 + bn.js: 5.2.1 + transitivePeerDependencies: + - bufferutil + - encoding + - utf-8-validate + dev: true + + /@marinade.finance/ts-common@2.0.20: + resolution: {integrity: sha512-DOr3VNCVxADBlQQ2tLDJEgpF974dsw8lwMvNObxLVyHhckR2aWnT+bIIxuuQxB+XqbRvyyS/gc2kXOKaQzu4Rg==} + + /@marinade.finance/web3js-common@2.0.20(@marinade.finance/ts-common@2.0.20)(@solana/web3.js@1.87.6)(bn.js@5.2.1)(borsh@0.7.0)(bs58@5.0.0): + resolution: {integrity: sha512-TBxIUfo0YDOU6MH3iZPQPuRuyKV7WMiFZPdOVE/01rj67TIivlVTiAFM4qOj+hIrNU93zrkGn4Bv2HTx8KbtDg==} engines: {node: '>=16.0.0'} peerDependencies: - '@marinade.finance/ts-common': 2.0.18 + '@marinade.finance/ts-common': 2.0.20 '@solana/web3.js': ^1.78.5 bn.js: ^5.2.1 borsh: ^0.7.0 bs58: ^5.0.0 dependencies: - '@marinade.finance/ts-common': 2.0.18 + '@marinade.finance/ts-common': 2.0.20 '@solana/web3.js': 1.87.6 bn.js: 5.2.1 borsh: 0.7.0 @@ -967,6 +1067,17 @@ packages: fastq: 1.15.0 dev: true + /@project-serum/borsh@0.2.5(@solana/web3.js@1.87.6): + resolution: {integrity: sha512-UmeUkUoKdQ7rhx6Leve1SssMR/Ghv8qrEiyywyxSWg7ooV7StdpPBhciiy5eB3T0qU1BXvdRNC8TdrkxK7WC5Q==} + engines: {node: '>=10'} + peerDependencies: + '@solana/web3.js': ^1.2.0 + dependencies: + '@solana/web3.js': 1.87.6 + bn.js: 5.2.1 + buffer-layout: 1.2.2 + dev: true + /@sinclair/typebox@0.27.8: resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} dev: true @@ -1002,6 +1113,47 @@ packages: dependencies: buffer: 6.0.3 + /@solana/spl-memo@0.2.3(@solana/web3.js@1.87.6): + resolution: {integrity: sha512-CNsKSsl85ebuVoeGq1LDYi5M/PMs1Pxv2/UsyTgS6b30qrYqZOXha5ouZzgGKtJtZ3C3dxfOAEw6caJPN1N63w==} + engines: {node: '>=16'} + peerDependencies: + '@solana/web3.js': ^1.20.0 + dependencies: + '@solana/web3.js': 1.87.6 + buffer: 6.0.3 + dev: true + + /@solana/spl-stake-pool@0.6.5: + resolution: {integrity: sha512-gAYjX4LlRem3Bje1csZOOBStX8wAH8b8tu4sublUTIoJxLMdEbXqnwc8RJ2lAsmFkjxxomEM9Hk65F8jcvv15A==} + dependencies: + '@project-serum/borsh': 0.2.5(@solana/web3.js@1.87.6) + '@solana/buffer-layout': 4.0.1 + '@solana/spl-token': 0.1.8 + '@solana/web3.js': 1.87.6 + bn.js: 5.2.1 + buffer: 6.0.3 + transitivePeerDependencies: + - bufferutil + - encoding + - utf-8-validate + dev: true + + /@solana/spl-token@0.1.8: + resolution: {integrity: sha512-LZmYCKcPQDtJgecvWOgT/cnoIQPWjdH+QVyzPcFvyDUiT0DiRjZaam4aqNUyvchLFhzgunv3d9xOoyE34ofdoQ==} + engines: {node: '>= 10'} + dependencies: + '@babel/runtime': 7.23.2 + '@solana/web3.js': 1.87.6 + bn.js: 5.2.1 + buffer: 6.0.3 + buffer-layout: 1.2.2 + dotenv: 10.0.0 + transitivePeerDependencies: + - bufferutil + - encoding + - utf-8-validate + dev: true + /@solana/spl-token@0.3.8(@solana/web3.js@1.87.6): resolution: {integrity: sha512-ogwGDcunP9Lkj+9CODOWMiVJEdRtqHAtX2rWF62KxnnSWtMZtV9rDhTrZFshiyJmxDnRL/1nKE1yJHg4jjs3gg==} engines: {node: '>=16'} @@ -1348,16 +1500,17 @@ packages: uri-js: 4.4.1 dev: true - /anchor-bankrun@0.2.0: - resolution: {integrity: sha512-k/GIiYjkFzj10iluAXPjLjDInZtNNZ2FTcCTrtqgxwm5wofLbD8T0sy13LU9IfQSirj8/6eaTawDo+IvI03KRQ==} + /anchor-bankrun@0.3.0(@coral-xyz/anchor@0.29.0)(@solana/web3.js@1.87.6)(solana-bankrun@0.2.0): + resolution: {integrity: sha512-PYBW5fWX+iGicIS5MIM/omhk1tQPUc0ELAnI/IkLKQJ6d75De/CQRh8MF2bU/TgGyFi6zEel80wUe3uRol9RrQ==} engines: {node: '>= 10'} + peerDependencies: + '@coral-xyz/anchor': ^0.28.0 || 0.29 + '@solana/web3.js': ^1.78.4 + solana-bankrun: ^0.2.0 dependencies: - '@coral-xyz/anchor': 0.28.0 + '@coral-xyz/anchor': 0.29.0 + '@solana/web3.js': 1.87.6 solana-bankrun: 0.2.0 - transitivePeerDependencies: - - bufferutil - - encoding - - utf-8-validate dev: true /ansi-escapes@4.3.2: @@ -1876,6 +2029,11 @@ packages: no-case: 3.0.4 tslib: 2.6.2 + /dotenv@10.0.0: + resolution: {integrity: sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==} + engines: {node: '>=10'} + dev: true + /electron-to-chromium@1.4.563: resolution: {integrity: sha512-dg5gj5qOgfZNkPNeyKBZQAQitIQ/xwfIDmEQJHCbXaD9ebTZxwJXUsDYcBlAvZGZLi+/354l35J1wkmP6CqYaw==} dev: true @@ -3382,6 +3540,7 @@ packages: /node-gyp-build@4.6.1: resolution: {integrity: sha512-24vnklJmyRS8ViBNI8KbtK/r/DmXQMRiOMXTNz2nrTnAYUwjmEEbnnpB/+kt+yWRv73bPsSPRFddrcIbAxSiMQ==} hasBin: true + requiresBuild: true /node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} diff --git a/programs/validator-bonds/src/instructions/stake/merge.rs b/programs/validator-bonds/src/instructions/stake/merge.rs index 6373d1bf..7081cb2b 100644 --- a/programs/validator-bonds/src/instructions/stake/merge.rs +++ b/programs/validator-bonds/src/instructions/stake/merge.rs @@ -27,6 +27,7 @@ pub struct Merge<'info> { destination_stake: Account<'info, StakeAccount>, /// CHECK: checked within the code + /// bonds program authority PDA address: settlement staker or bonds withdrawer #[account()] staker_authority: UncheckedAccount<'info>, @@ -50,7 +51,7 @@ impl<'info> Merge<'info> { .meta() .ok_or(error!(ErrorCode::UninitializedStake).with_account_name("source_stake"))?; - // staker authorities has to match each other, verification if it belongs to bond is down in switch statement + // staker authorities has to match each other, intentional not permitting to pass stake account merge authority if destination_meta.authorized.staker != self.staker_authority.key() { return Err(error!(ErrorCode::StakerAuthorityMismatch) .with_account_name("destination_stake")