diff --git a/package.json b/package.json index f3235faf..71175289 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "ts-jest": "^29.1.1", "ts-node": "^10.9.1", "typescript": "4.9.5", - "@marinade.finance/jest-utils": "^2.1.0" + "@marinade.finance/jest-utils": "^2.1.1" }, "pnpm": { "peerDependencyRules": { diff --git a/packages/validator-bonds-cli/__tests__/test-validator/merge.spec.ts b/packages/validator-bonds-cli/__tests__/test-validator/merge.spec.ts index 5c90c24d..34cb5e4d 100644 --- a/packages/validator-bonds-cli/__tests__/test-validator/merge.spec.ts +++ b/packages/validator-bonds-cli/__tests__/test-validator/merge.spec.ts @@ -7,6 +7,7 @@ import { import { AnchorExtendedProvider, initTest, + waitForNextEpoch, } from '@marinade.finance/validator-bonds-sdk/__tests__/test-validator/testValidator' import { executeInitConfigInstruction } from '@marinade.finance/validator-bonds-sdk/__tests__/utils/testTransactions' import { @@ -23,6 +24,10 @@ describe('Merge stake accounts using CLI', () => { beforeAll(async () => { shellMatchers() ;({ provider, program } = await initTest()) + // we want to be at the beginning of the epoch + // otherwise the merge instruction could fail as the stake account is in different state (0x6) + // https://github.com/solana-labs/solana/blob/v1.17.15/sdk/program/src/stake/instruction.rs#L42 + await waitForNextEpoch(provider.connection, 15) }) beforeEach(async () => { diff --git a/packages/validator-bonds-cli/package.json b/packages/validator-bonds-cli/package.json index e52bcce0..ed1e0ebb 100644 --- a/packages/validator-bonds-cli/package.json +++ b/packages/validator-bonds-cli/package.json @@ -27,11 +27,11 @@ "@marinade.finance/validator-bonds-sdk": "^1.1.5", "@coral-xyz/anchor": "^0.29.0", "@solana/web3.js": "^1.87.6", - "@marinade.finance/cli-common": "^2.1.0", - "@marinade.finance/anchor-common": "^2.1.0", - "@marinade.finance/web3js-common": "^2.1.0", - "@marinade.finance/ledger-utils": "^2.1.0", - "@marinade.finance/ts-common": "^2.1.0", + "@marinade.finance/cli-common": "^2.1.1", + "@marinade.finance/anchor-common": "^2.1.1", + "@marinade.finance/web3js-common": "^2.1.1", + "@marinade.finance/ledger-utils": "^2.1.1", + "@marinade.finance/ts-common": "^2.1.1", "bn.js": "^5.2.1", "jsbi": "^4.3.0", "commander": "^9.5.0", @@ -41,6 +41,6 @@ "yaml": "^2.3.3" }, "devDependencies": { - "@marinade.finance/jest-utils": "^2.1.0" + "@marinade.finance/jest-utils": "^2.1.1" } } diff --git a/packages/validator-bonds-sdk/__tests__/bankrun/bankrun.ts b/packages/validator-bonds-sdk/__tests__/bankrun/bankrun.ts index 62cc3fde..1aa660dd 100644 --- a/packages/validator-bonds-sdk/__tests__/bankrun/bankrun.ts +++ b/packages/validator-bonds-sdk/__tests__/bankrun/bankrun.ts @@ -3,6 +3,7 @@ import { ValidatorBondsProgram, getProgram } from '../../src' import { BanksTransactionMeta, startAnchor } from 'solana-bankrun' import { BankrunProvider } from 'anchor-bankrun' import { + Keypair, PublicKey, Signer, Transaction, @@ -17,7 +18,7 @@ export class BankrunExtendedProvider implements ExtendedProvider { async sendIx( - signers: (WalletInterface | Signer)[], + signers: (WalletInterface | Signer | Keypair)[], ...ixes: ( | Transaction | TransactionInstruction @@ -62,7 +63,7 @@ export async function bankrunTransaction( export async function bankrunExecuteIx( provider: BankrunProvider, - signers: (WalletInterface | Signer)[], + signers: (WalletInterface | Signer | Keypair)[], ...ixes: ( | Transaction | TransactionInstruction @@ -76,13 +77,13 @@ export async function bankrunExecuteIx( export async function bankrunExecute( provider: BankrunProvider, - signers: (WalletInterface | Signer)[], + signers: (WalletInterface | Signer | Keypair)[], tx: Transaction ): Promise { for (const signer of signers) { if (instanceOfWallet(signer)) { await signer.signTransaction(tx) - } else if ('secretKey' in signer) { + } else if (signer instanceof Keypair || 'secretKey' in signer) { tx.partialSign(signer) } else { throw new Error( diff --git a/packages/validator-bonds-sdk/__tests__/bankrun/claimWithdrawRequest.spec.ts b/packages/validator-bonds-sdk/__tests__/bankrun/claimWithdrawRequest.spec.ts index 9ace7c2a..28b3f8b1 100644 --- a/packages/validator-bonds-sdk/__tests__/bankrun/claimWithdrawRequest.spec.ts +++ b/packages/validator-bonds-sdk/__tests__/bankrun/claimWithdrawRequest.spec.ts @@ -2,17 +2,22 @@ import { Bond, Config, ValidatorBondsProgram, + cancelWithdrawRequestInstruction, getBond, getConfig, getWithdrawRequest, + settlementAuthority, + withdrawerAuthority, } from '../../src' import { BankrunExtendedProvider, + assertNotExist, initBankrunTest, warpToEpoch, warpToNextEpoch, } from './bankrun' import { + createUserAndFund, executeFundBondInstruction, executeInitBondInstruction, executeInitConfigInstruction, @@ -21,8 +26,17 @@ import { import { ProgramAccount } from '@coral-xyz/anchor' import { Keypair, LAMPORTS_PER_SOL, PublicKey } from '@solana/web3.js' import { claimWithdrawRequestInstruction } from '../../src/instructions/claimWithdrawRequest' -import { delegatedStakeAccount } from '../utils/staking' -import { checkAnchorErrorMessage } from '../utils/helpers' +import { + authorizeStakeAccount, + delegatedStakeAccount, + deserializeStakeState, + initializedStakeAccount, +} from '../utils/staking' +import { checkAnchorErrorMessage, pubkey } from '../utils/helpers' +import assert from 'assert' +import BN from 'bn.js' + +// TODO: test the merging stake accounts through the orchestrate withdraw request, i.e., test orchestrators/orchestrateWithdrawRequest.ts describe('Validator Bonds claim withdraw request', () => { let provider: BankrunExtendedProvider @@ -30,8 +44,10 @@ describe('Validator Bonds claim withdraw request', () => { let config: ProgramAccount let bond: ProgramAccount let validatorIdentity: Keypair + let bondAuthority: Keypair let voteAccount: PublicKey const startUpEpoch = Math.floor(Math.random() * 100) + 100 + const withdrawLockupEpochs = 1 beforeAll(async () => { ;({ provider, program } = await initBankrunTest()) @@ -42,7 +58,7 @@ describe('Validator Bonds claim withdraw request', () => { const { configAccount } = await executeInitConfigInstruction({ program, provider, - withdrawLockupEpochs: 2, + withdrawLockupEpochs, }) config = { publicKey: configAccount, @@ -52,8 +68,10 @@ describe('Validator Bonds claim withdraw request', () => { bondAccount, validatorIdentity: nodeIdentity, voteAccount: voteAcc, + bondAuthority: bondAuth, } = await executeInitBondInstruction(program, provider, config.publicKey) voteAccount = voteAcc + bondAuthority = bondAuth validatorIdentity = nodeIdentity bond = { publicKey: bondAccount, @@ -62,34 +80,15 @@ describe('Validator Bonds claim withdraw request', () => { }) it('claim withdraw request with split stake account created', async () => { - const { stakeAccount, withdrawer: stakeAccountWithdrawer } = - await delegatedStakeAccount({ - provider, - lamports: 4 * LAMPORTS_PER_SOL, - voteAccountToDelegate: voteAccount, - }) - await warpToNextEpoch(provider) // activating stake account - await executeFundBondInstruction({ - program, - provider, - bondAccount: bond.publicKey, - stakeAccount, - stakeAccountAuthority: stakeAccountWithdrawer, - }) + const epochAtTestStart = Number( + (await provider.context.banksClient.getClock()).epoch + ) + + const initAmount = 5 * LAMPORTS_PER_SOL const requestedAmount = LAMPORTS_PER_SOL * 2 - const { withdrawRequest } = await executeInitWithdrawRequestInstruction({ - program, - provider, - bondAccount: bond.publicKey, - validatorIdentity, - // TODO: test that asking for more than available fails - // TODO: test the amount to be smaller than the minimum (1 SOL + 1) and the split can't happen - // TODO: test SDK - // TODO: withdrawing with several different stake accounts - // TODO: test the merging stake accounts through the orchestrate withdraw request - // TODO: try to claim all first and then claim more on top of the requested amount - amount: requestedAmount, - }) + const { withdrawRequest, stakeAccount } = + await createStakeAccountAndInitWithdraw(initAmount, requestedAmount) + let withdrawRequestData = await getWithdrawRequest(program, withdrawRequest) expect(withdrawRequestData.validatorVoteAccount).toEqual(voteAccount) expect(withdrawRequestData.withdrawnAmount).toEqual(0) @@ -98,6 +97,7 @@ describe('Validator Bonds claim withdraw request', () => { const { instruction, splitStakeAccount } = await claimWithdrawRequestInstruction({ program, + authority: validatorIdentity, withdrawRequestAccount: withdrawRequest, bondAccount: bond.publicKey, stakeAccount, @@ -106,85 +106,636 @@ describe('Validator Bonds claim withdraw request', () => { // waiting an epoch but not enough to unlock await warpToNextEpoch(provider) try { - await provider.sendIx([splitStakeAccount], instruction) + await provider.sendIx([splitStakeAccount, validatorIdentity], instruction) throw new Error('Expected withdraw request should not be elapsed') } catch (err) { checkAnchorErrorMessage(err, 6019, 'Withdraw request has not elapsed') } - // withdrawLockupEpochs is 2, then second warp should make the withdraw request unlocked + // withdrawLockupEpochs is 1, then the warp should make the withdraw request unlocked await warpToNextEpoch(provider) - await warpToNextEpoch(provider) - await provider.sendIx([splitStakeAccount], instruction) + await provider.sendIx([splitStakeAccount, validatorIdentity], instruction) // withdraw request exists until is cancelled withdrawRequestData = await getWithdrawRequest(program, withdrawRequest) expect(withdrawRequestData.withdrawnAmount).toEqual(requestedAmount) expect(withdrawRequestData.requestedAmount).toEqual(requestedAmount) - // TODO: finalize checks here const originalStakeAccountInfo = await provider.connection.getAccountInfo( stakeAccount ) expect(originalStakeAccountInfo?.lamports).toEqual(requestedAmount) - const splitStakeAccountInfo = await provider.connection.getAccountInfo( - splitStakeAccount.publicKey + + assert( + originalStakeAccountInfo !== null, + 'original stake account not found' ) - if (splitStakeAccountInfo === null) { - throw new Error( - `claiming split stake account '${splitStakeAccount.publicKey.toBase58()} not found` - ) - } const rentExemptStakeAccount = await provider.connection.getMinimumBalanceForRentExemption( - splitStakeAccountInfo.data.length + originalStakeAccountInfo.data.length ) console.log('rentExemptStakeAccount', rentExemptStakeAccount) - expect(splitStakeAccountInfo.lamports).toEqual( - requestedAmount + rentExemptStakeAccount + + // -------- ORIGINAL STAKE ACCOUNT -------- + const originalStakeAccountData = deserializeStakeState( + originalStakeAccountInfo.data + ) + expect(originalStakeAccountData.Stake?.meta.authorized.staker).toEqual( + validatorIdentity.publicKey + ) + expect(originalStakeAccountData.Stake?.meta.authorized.withdrawer).toEqual( + validatorIdentity.publicKey + ) + expect(originalStakeAccountData.Stake?.meta.lockup.epoch).toEqual(0) + expect(originalStakeAccountData.Stake?.meta.lockup.unixTimestamp).toEqual(0) + expect(originalStakeAccountData.Stake?.meta.rentExemptReserve).toEqual( + rentExemptStakeAccount + ) + expect(originalStakeAccountData.Stake?.stake.delegation.stake).toEqual( + requestedAmount - rentExemptStakeAccount + ) + expect( + originalStakeAccountData.Stake?.stake.delegation.voterPubkey + ).toEqual(voteAccount) + expect( + originalStakeAccountData.Stake?.stake.delegation.activationEpoch.toNumber() + ).toEqual(epochAtTestStart) + expect( + new BN( + originalStakeAccountData.Stake!.stake.delegation.deactivationEpoch.toString() + ).gt(new BN(epochAtTestStart)) + ).toBeTruthy() + + // -------- SPLIT STAKE ACCOUNT -------- + const splitStakeAccountInfo = await provider.connection.getAccountInfo( + splitStakeAccount.publicKey + ) + expect(splitStakeAccountInfo).not.toBeNull() + expect(splitStakeAccountInfo?.lamports).toEqual( + initAmount - requestedAmount + rentExemptStakeAccount + ) + assert(splitStakeAccountInfo !== null, 'split stake account not found') + + const [bondsAuthority] = withdrawerAuthority( + config.publicKey, + program.programId + ) + const splitStakeAccountData = deserializeStakeState( + splitStakeAccountInfo?.data + ) + expect(splitStakeAccountData.Stake?.meta.authorized.withdrawer).toEqual( + bondsAuthority + ) + expect(splitStakeAccountData.Stake?.meta.authorized.staker).toEqual( + bondsAuthority + ) + expect(splitStakeAccountData.Stake?.meta.lockup.epoch).toEqual(0) + expect(splitStakeAccountData.Stake?.meta.lockup.unixTimestamp).toEqual(0) + expect(splitStakeAccountData.Stake?.meta.rentExemptReserve).toEqual( + rentExemptStakeAccount + ) + expect(splitStakeAccountData.Stake?.stake.delegation.stake).toEqual( + initAmount - requestedAmount ) + expect(splitStakeAccountData.Stake?.stake.delegation.voterPubkey).toEqual( + voteAccount + ) + expect( + splitStakeAccountData.Stake?.stake.delegation.activationEpoch.toNumber() + ).toEqual(epochAtTestStart) + expect( + new BN( + splitStakeAccountData.Stake!.stake.delegation.deactivationEpoch.toString() + ).gt(new BN(epochAtTestStart)) + ).toBeTruthy() }) it('claim withdraw request with stake fulfilling the whole', async () => { - const { stakeAccount, withdrawer: stakeAccountWithdrawer } = - await delegatedStakeAccount({ - provider, - lamports: 2 * LAMPORTS_PER_SOL, - voteAccountToDelegate: voteAccount, + const epochAtTestStart = Number( + (await provider.context.banksClient.getClock()).epoch + ) + + const requestedAmount = 2 * LAMPORTS_PER_SOL + const { withdrawRequest, stakeAccount } = + await createStakeAccountAndInitWithdraw(requestedAmount, requestedAmount) + + const { instruction, splitStakeAccount } = + await claimWithdrawRequestInstruction({ + program, + authority: validatorIdentity, + withdrawRequestAccount: withdrawRequest, + bondAccount: bond.publicKey, + stakeAccount, }) - await warpToNextEpoch(provider) // activating stake account - await executeFundBondInstruction({ + + await warpToUnlock() + await provider.sendIx([splitStakeAccount, validatorIdentity], instruction) + + // withdraw request exists until is cancelled + const withdrawRequestData = await getWithdrawRequest( program, + withdrawRequest + ) + expect(withdrawRequestData.withdrawnAmount).toEqual(requestedAmount) + expect(withdrawRequestData.requestedAmount).toEqual(requestedAmount) + + const originalStakeAccountInfo = await provider.connection.getAccountInfo( + stakeAccount + ) + expect(originalStakeAccountInfo?.lamports).toEqual(requestedAmount) + + assert( + originalStakeAccountInfo !== null, + 'original stake account not found' + ) + const rentExemptStakeAccount = + await provider.connection.getMinimumBalanceForRentExemption( + originalStakeAccountInfo.data.length + ) + + // -------- ORIGINAL STAKE ACCOUNT -------- + const originalStakeAccountData = deserializeStakeState( + originalStakeAccountInfo.data + ) + expect(originalStakeAccountData.Stake?.meta.authorized.staker).toEqual( + validatorIdentity.publicKey + ) + expect(originalStakeAccountData.Stake?.meta.authorized.withdrawer).toEqual( + validatorIdentity.publicKey + ) + expect(originalStakeAccountData.Stake?.meta.lockup.epoch).toEqual(0) + expect(originalStakeAccountData.Stake?.meta.lockup.unixTimestamp).toEqual(0) + expect(originalStakeAccountData.Stake?.meta.rentExemptReserve).toEqual( + rentExemptStakeAccount + ) + expect(originalStakeAccountData.Stake?.stake.delegation.stake).toEqual( + requestedAmount - rentExemptStakeAccount + ) + expect( + originalStakeAccountData.Stake?.stake.delegation.voterPubkey + ).toEqual(voteAccount) + expect( + originalStakeAccountData.Stake?.stake.delegation.activationEpoch.toNumber() + ).toEqual(epochAtTestStart) + expect( + new BN( + originalStakeAccountData.Stake!.stake.delegation.deactivationEpoch.toString() + ).gt(new BN(epochAtTestStart)) + ).toBeTruthy() + // -------- SPLIT STAKE ACCOUNT -------- + await assertNotExist(provider, splitStakeAccount.publicKey) + + // double claiming the withdraw request should fail + await warpToNextEpoch(provider) + try { + await provider.sendIx([splitStakeAccount, validatorIdentity], instruction) + throw new Error('failure expected; already claimed') + } catch (err) { + checkAnchorErrorMessage(err, 6047, 'already fulfilled') + } + }) + + it('claim withdraw with full SDK usage', async () => { + const requestedAmount = 2 * LAMPORTS_PER_SOL + const stakeAccountAmount = 20 * LAMPORTS_PER_SOL + const { withdrawRequest, stakeAccount } = + await createStakeAccountAndInitWithdraw( + stakeAccountAmount, + requestedAmount + ) + const rentPayerUser = await createUserAndFund( provider, - bondAccount: bond.publicKey, - stakeAccount, - stakeAccountAuthority: stakeAccountWithdrawer, - }) - const { withdrawRequest } = await executeInitWithdrawRequestInstruction({ - program, + undefined, + LAMPORTS_PER_SOL + ) + const withdrawer = await createUserAndFund( provider, - bondAccount: bond.publicKey, - validatorIdentity, - amount: LAMPORTS_PER_SOL * 2, + undefined, + LAMPORTS_PER_SOL + ) + const { instruction, splitStakeAccount } = + await claimWithdrawRequestInstruction({ + program, + authority: bondAuthority, + withdrawRequestAccount: withdrawRequest, + bondAccount: bond.publicKey, + stakeAccount, + configAccount: config.publicKey, + splitStakeRentPayer: rentPayerUser, + validatorVoteAccount: voteAccount, + withdrawer: pubkey(withdrawer), + }) + await warpToUnlock() + await provider.sendIx( + [splitStakeAccount, rentPayerUser, bondAuthority], + instruction + ) + + const originalStakeAccountInfo = await provider.connection.getAccountInfo( + stakeAccount + ) + expect(originalStakeAccountInfo?.lamports).toEqual(requestedAmount) + assert(originalStakeAccountInfo !== null) + const originalStakeAccountData = deserializeStakeState( + originalStakeAccountInfo.data + ) + expect(originalStakeAccountData.Stake?.meta.authorized.staker).toEqual( + pubkey(withdrawer) + ) + expect(originalStakeAccountData.Stake?.meta.authorized.withdrawer).toEqual( + pubkey(withdrawer) + ) + const splitStakeAccountInfo = await provider.connection.getAccountInfo( + splitStakeAccount.publicKey + ) + expect(splitStakeAccountInfo).not.toBeNull() + assert(splitStakeAccountInfo !== null, 'split stake account not found') + const rentExemptStakeAccount = + await provider.connection.getMinimumBalanceForRentExemption( + splitStakeAccountInfo.data.length + ) + expect( + (await provider.connection.getAccountInfo(rentPayerUser.publicKey)) + ?.lamports + ).toEqual(LAMPORTS_PER_SOL - rentExemptStakeAccount) + }) + + it('fail to claim on wrong split size amount', async () => { + const requestedAmount = 321 * LAMPORTS_PER_SOL + const stakeAmount = 320 * LAMPORTS_PER_SOL + const { withdrawRequest, stakeAccount } = + await createStakeAccountAndInitWithdraw(stakeAmount, requestedAmount) + const { stakeAccount: stakeAccountCannotSplit } = await delegateAndFund( + 2 * LAMPORTS_PER_SOL + ) + const { stakeAccount: stakeAccountCannotSplit2 } = await delegateAndFund( + 3 * LAMPORTS_PER_SOL + ) + await warpToUnlock() + + // partially fulfill + const { instruction: ix1, splitStakeAccount: split1 } = + await claimWithdrawRequestInstruction({ + program, + authority: validatorIdentity, + withdrawRequestAccount: withdrawRequest, + stakeAccount, + }) + await provider.sendIx([split1, validatorIdentity], ix1) + + const { instruction: ixCannotSplit, splitStakeAccount: splitCannotSplit } = + await claimWithdrawRequestInstruction({ + program, + authority: bondAuthority, + withdrawRequestAccount: withdrawRequest, + stakeAccount: stakeAccountCannotSplit, + }) + try { + await provider.sendIx([splitCannotSplit, bondAuthority], ixCannotSplit) + throw new Error('failure expected; cannot split') + } catch (err) { + checkAnchorErrorMessage(err, 6029, 'Stake account is not big enough') + } + + const { + instruction: ixCannotSplit2, + splitStakeAccount: splitCannotSplit2, + } = await claimWithdrawRequestInstruction({ + program, + authority: validatorIdentity, + withdrawRequestAccount: withdrawRequest, + stakeAccount: stakeAccountCannotSplit2, }) + try { + await provider.sendIx( + [splitCannotSplit2, validatorIdentity], + ixCannotSplit2 + ) + throw new Error('failure expected; cannot split') + } catch (err) { + checkAnchorErrorMessage(err, 6046, 'cancel and init new one') + } + }) + + it('fail to claim when split is less to 1 SOL', async () => { + const { withdrawRequest, stakeAccount } = + await createStakeAccountAndInitWithdraw( + 4 * LAMPORTS_PER_SOL, + LAMPORTS_PER_SOL * 3 + ) + + const { instruction, splitStakeAccount } = + await claimWithdrawRequestInstruction({ + program, + authority: validatorIdentity, + withdrawRequestAccount: withdrawRequest, + bondAccount: bond.publicKey, + stakeAccount, + }) + + await warpToUnlock() + try { + await provider.sendIx([splitStakeAccount, validatorIdentity], instruction) + throw new Error('is less to 1 SOL: should fail as split is not possible') + } catch (err) { + checkAnchorErrorMessage(err, 6029, 'not big enough to be split') + } + }) + + it('claim more different stake accounts and cancel', async () => { + const requestedAmount = 10 * LAMPORTS_PER_SOL + const stake1Amount = 2 * LAMPORTS_PER_SOL + const stake2Amount = 3 * LAMPORTS_PER_SOL + const stake3Amount = 1.5 * LAMPORTS_PER_SOL + const { withdrawRequest, stakeAccount: stakeAccount1 } = + await createStakeAccountAndInitWithdraw(stake1Amount, requestedAmount) + const { stakeAccount: stakeAccount2 } = await delegateAndFund(stake2Amount) + const { stakeAccount: stakeAccount3 } = await delegateAndFund(stake3Amount) + + const { instruction: ix1, splitStakeAccount: split1 } = + await claimWithdrawRequestInstruction({ + program, + authority: validatorIdentity, + withdrawRequestAccount: withdrawRequest, + bondAccount: bond.publicKey, + stakeAccount: stakeAccount1, + }) + const { instruction: ix2, splitStakeAccount: split2 } = + await claimWithdrawRequestInstruction({ + program, + authority: validatorIdentity, + withdrawRequestAccount: withdrawRequest, + bondAccount: bond.publicKey, + stakeAccount: stakeAccount2, + }) + const { instruction: ix3, splitStakeAccount: split3 } = + await claimWithdrawRequestInstruction({ + program, + authority: validatorIdentity, + withdrawRequestAccount: withdrawRequest, + bondAccount: bond.publicKey, + stakeAccount: stakeAccount3, + }) + + await warpToUnlock() + await provider.sendIx( + [split1, split2, split3, validatorIdentity], + ix1, + ix2, + ix3 + ) const withdrawRequestData = await getWithdrawRequest( program, withdrawRequest ) - expect(withdrawRequestData.validatorVoteAccount).toEqual(voteAccount) + expect(withdrawRequestData.requestedAmount).toEqual(requestedAmount) + expect(withdrawRequestData.withdrawnAmount).toEqual( + stake1Amount + stake2Amount + stake3Amount + ) + + const { instruction: cancelIx } = await cancelWithdrawRequestInstruction({ + program, + withdrawRequestAccount: withdrawRequest, + bondAccount: bond.publicKey, + authority: validatorIdentity, + }) + await provider.sendIx([validatorIdentity], cancelIx) + await assertNotExist(provider, withdrawRequest) + }) + + it('cannot claim with wrong bonds authority', async () => { + const wrongAuthority = Keypair.generate() + const { stakeAccount, withdrawRequest } = + await createStakeAccountAndInitWithdraw( + 2 * LAMPORTS_PER_SOL, + 10 * LAMPORTS_PER_SOL + ) + await warpToUnlock() + const { instruction, splitStakeAccount } = + await claimWithdrawRequestInstruction({ + program, + authority: wrongAuthority.publicKey, + withdrawRequestAccount: withdrawRequest, + stakeAccount, + }) + + try { + await provider.sendIx([splitStakeAccount, wrongAuthority], instruction) + throw new Error('failure expected; wrong authority to claim') + } catch (e) { + checkAnchorErrorMessage(e, 6002, 'Invalid authority to operate') + } + }) + it('cannot claim with non-delegated stake account', async () => { + const { stakeAccount: nonDelegatedStakeAccount } = + await initializedStakeAccount(provider) + const { withdrawRequest } = await initWithdrawRequest(2 * LAMPORTS_PER_SOL) + await warpToUnlock() const { instruction, splitStakeAccount } = await claimWithdrawRequestInstruction({ program, + authority: validatorIdentity, + withdrawRequestAccount: withdrawRequest, + bondAccount: bond.publicKey, + stakeAccount: nonDelegatedStakeAccount, + }) + + try { + await provider.sendIx([splitStakeAccount, validatorIdentity], instruction) + throw new Error('failure expected; stake account is not delegated') + } catch (e) { + checkAnchorErrorMessage(e, 6017, 'cannot be used for bonds') + } + }) + + it('cannot claim with wrong delegation', async () => { + const { withdrawRequest } = await initWithdrawRequest(4 * LAMPORTS_PER_SOL) + await warpToUnlock() + + const { stakeAccount } = await delegatedStakeAccount({ + provider, + lamports: LAMPORTS_PER_SOL * 2, + }) + + const { instruction, splitStakeAccount } = + await claimWithdrawRequestInstruction({ + program, + authority: validatorIdentity, withdrawRequestAccount: withdrawRequest, bondAccount: bond.publicKey, stakeAccount, }) - // waiting an epoch but not enough to unlock + try { + await provider.sendIx([splitStakeAccount, validatorIdentity], instruction) + throw new Error('failure expected as not activated') + } catch (e) { + checkAnchorErrorMessage(e, 6023, 'not fully activated') + } + await warpToNextEpoch(provider) + try { + await provider.sendIx([splitStakeAccount, validatorIdentity], instruction) + throw new Error('failure expected as delegated to wrong validator') + } catch (e) { + checkAnchorErrorMessage(e, 6018, 'delegated to a wrong validator') + } + }) + + it('cannot claim with wrong stake account authority', async () => { + const stakeAccountStaker = new Keypair() + const stakeAccountWithdrawer = new Keypair() + const [bondsAuth] = withdrawerAuthority(config.publicKey, program.programId) + const [settlementAuth] = settlementAuthority( + new Keypair().publicKey, + program.programId + ) + + const { stakeAccount, withdrawer } = await delegatedStakeAccount({ + provider, + lamports: LAMPORTS_PER_SOL * 2, + staker: stakeAccountStaker, + withdrawer: stakeAccountWithdrawer, + voteAccountToDelegate: voteAccount, + }) + const { withdrawRequest } = await initWithdrawRequest(4 * LAMPORTS_PER_SOL) + await warpToUnlock() + + const { instruction, splitStakeAccount } = + await claimWithdrawRequestInstruction({ + program, + authority: validatorIdentity, + withdrawRequestAccount: withdrawRequest, + bondAccount: bond.publicKey, + stakeAccount, + }) + + try { + await provider.sendIx([splitStakeAccount, validatorIdentity], instruction) + throw new Error('failure expected; wrong withdrawer') + } catch (e) { + checkAnchorErrorMessage(e, 6010, 'Wrong withdrawer authority') + } + + await authorizeStakeAccount({ + provider, + stakeAccount, + authority: withdrawer, + staker: settlementAuth, + }) await warpToNextEpoch(provider) + try { + await provider.sendIx([splitStakeAccount, validatorIdentity], instruction) + throw new Error('failure expected; wrong withdrawer') + } catch (e) { + checkAnchorErrorMessage(e, 6010, 'Wrong withdrawer authority') + } + + await authorizeStakeAccount({ + provider, + stakeAccount, + authority: withdrawer, + withdrawer: bondsAuth, + }) await warpToNextEpoch(provider) - await provider.sendIx([splitStakeAccount], instruction) + try { + await provider.sendIx([splitStakeAccount, validatorIdentity], instruction) + throw new Error('failure expected; wrong staker') + } catch (e) { + checkAnchorErrorMessage(e, 6026, 'already funded to a settlement') + } + }) + + it('cannot claim with lockup delegation', async () => { + const futureEpoch = + Number((await provider.context.banksClient.getClock()).epoch) + 10 + const { stakeAccount } = await delegatedStakeAccount({ + provider, + lamports: LAMPORTS_PER_SOL * 2, + voteAccountToDelegate: bond.account.validatorVoteAccount, + lockup: { + custodian: Keypair.generate().publicKey, + // locked up epoch is bigger than to one we will warp to + epoch: futureEpoch + 1, + unixTimestamp: 0, + }, + }) + const { withdrawRequest } = await initWithdrawRequest(33 * LAMPORTS_PER_SOL) + await warpToUnlock() + + const { instruction, splitStakeAccount } = + await claimWithdrawRequestInstruction({ + program, + authority: validatorIdentity, + withdrawRequestAccount: withdrawRequest, + bondAccount: bond.publicKey, + stakeAccount, + }) + + warpToEpoch(provider, futureEpoch) + try { + await provider.sendIx([splitStakeAccount, validatorIdentity], instruction) + throw new Error('failure expected as should be locked') + } catch (e) { + checkAnchorErrorMessage(e, 6028, 'stake account is locked-up') + } }) + + async function warpToUnlock() { + // waiting two epochs to unlock; the first one is not enough (withdrawLockupEpochs = 1) + expect(withdrawLockupEpochs).toEqual(1) + await warpToNextEpoch(provider) + await warpToNextEpoch(provider) + } + + async function createStakeAccountAndInitWithdraw( + fundStakeLamports: number, + initWithdrawAmount: number + ): Promise<{ + withdrawRequest: PublicKey + stakeAccount: PublicKey + }> { + const { stakeAccount } = await delegateAndFund(fundStakeLamports) + const { withdrawRequest } = await initWithdrawRequest(initWithdrawAmount) + return { withdrawRequest, stakeAccount } + } + + async function initWithdrawRequest( + initWithdrawAmount: number + ): Promise<{ withdrawRequest: PublicKey }> { + const { withdrawRequest } = await executeInitWithdrawRequestInstruction({ + program, + provider, + bondAccount: bond.publicKey, + validatorIdentity, + amount: initWithdrawAmount, + }) + const withdrawRequestData = await getWithdrawRequest( + program, + withdrawRequest + ) + expect(withdrawRequestData.validatorVoteAccount).toEqual(voteAccount) + return { withdrawRequest } + } + + async function delegateAndFund( + amountLamports: number + ): Promise<{ stakeAccount: PublicKey }> { + const { stakeAccount, withdrawer: stakeAccountWithdrawer } = + await delegatedStakeAccount({ + provider, + lamports: amountLamports, + voteAccountToDelegate: voteAccount, + }) + await warpToNextEpoch(provider) // activating stake account + await executeFundBondInstruction({ + program, + provider, + bondAccount: bond.publicKey, + stakeAccount, + stakeAccountAuthority: stakeAccountWithdrawer, + }) + return { stakeAccount } + } }) diff --git a/packages/validator-bonds-sdk/__tests__/bankrun/fundBond.spec.ts b/packages/validator-bonds-sdk/__tests__/bankrun/fundBond.spec.ts index c60c9ba5..6c86ed18 100644 --- a/packages/validator-bonds-sdk/__tests__/bankrun/fundBond.spec.ts +++ b/packages/validator-bonds-sdk/__tests__/bankrun/fundBond.spec.ts @@ -9,6 +9,7 @@ import { } from '../../src' import { BankrunExtendedProvider, + bankrunExecuteIx, initBankrunTest, warpToEpoch, warpToNextEpoch, @@ -109,6 +110,7 @@ describe('Validator Bonds fund bond account', () => { await warpToNextEpoch(provider) try { await provider.sendIx([withdrawer], instruction) + throw new Error('failure expected as delegated to wrong validator') } catch (e) { checkAnchorErrorMessage(e, 6018, 'delegated to a wrong validator') } @@ -195,5 +197,18 @@ describe('Validator Bonds fund bond account', () => { epoch: new BN(0), unixTimestamp: new BN(0), }) + + // double funding the same account + await warpToNextEpoch(provider) + const txRet = await bankrunExecuteIx( + provider, + [provider.wallet, withdrawer], + instruction + ) + expect( + txRet.logMessages.find(m => + m.includes('is already owned by the bonds program') + ) + ).toBeDefined() }) }) diff --git a/packages/validator-bonds-sdk/__tests__/utils/staking.ts b/packages/validator-bonds-sdk/__tests__/utils/staking.ts index de6b9235..76a98cc4 100644 --- a/packages/validator-bonds-sdk/__tests__/utils/staking.ts +++ b/packages/validator-bonds-sdk/__tests__/utils/staking.ts @@ -30,7 +30,10 @@ import { pubkey } from './helpers' 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 { +export function deserializeStakeState(data: Buffer | undefined): StakeState { + if (data === null || data === undefined) { + throw new Error('StakeState data buffer is missing') + } // 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([ diff --git a/packages/validator-bonds-sdk/__tests__/utils/testTransactions.ts b/packages/validator-bonds-sdk/__tests__/utils/testTransactions.ts index 16ccf12b..dedd0875 100644 --- a/packages/validator-bonds-sdk/__tests__/utils/testTransactions.ts +++ b/packages/validator-bonds-sdk/__tests__/utils/testTransactions.ts @@ -20,8 +20,6 @@ import { ExtendedProvider } from './provider' import { createVoteAccount } from './staking' import BN from 'bn.js' import assert from 'assert' -// import { BankrunExtendedProvider, warpToNextEpoch } from '../bankrun/bankrun' -// import { waitForStakeAccountActivation } from '../test-validator/testValidator' export async function createUserAndFund( provider: ExtendedProvider, @@ -71,7 +69,7 @@ export async function executeWithdraw( await provider.sendIx([withdrawAuthority], withdrawIx) } catch (e) { console.error( - `executeWithdraw: withdraw ${stakeAccount.toBase58()}, ` + + `[executeWithdraw] stake account: ${stakeAccount.toBase58()}, ` + `withdrawer: ${withdrawAuthority.publicKey.toBase58()}`, e ) diff --git a/packages/validator-bonds-sdk/generated/validator_bonds.ts b/packages/validator-bonds-sdk/generated/validator_bonds.ts index 2b11e037..3d42f15e 100644 --- a/packages/validator-bonds-sdk/generated/validator_bonds.ts +++ b/packages/validator-bonds-sdk/generated/validator_bonds.ts @@ -549,6 +549,14 @@ export type ValidatorBonds = { "isMut": false, "isSigner": false }, + { + "name": "authority", + "isMut": false, + "isSigner": true, + "docs": [ + "validator vote account node identity or bond authority may claim" + ] + }, { "name": "withdrawRequest", "isMut": true, @@ -604,11 +612,10 @@ export type ValidatorBonds = { }, { "name": "withdrawer", - "isMut": true, + "isMut": false, "isSigner": false, "docs": [ - "This is the account that will be the new owner (withdrawer authority) of the stake account", - "and ultimately it receives the withdrawing funds" + "New owner of the stake account, it will be accounted to the withdrawer authority" ] }, { @@ -2919,8 +2926,8 @@ export type ValidatorBonds = { }, { "code": 6010, - "name": "InvalidStakeOwner", - "msg": "Stake account's withdrawer does not match with the provided owner" + "name": "WrongStakeAccountWithdrawer", + "msg": "Wrong withdrawer authority of the stake account" }, { "code": 6011, @@ -2999,7 +3006,7 @@ export type ValidatorBonds = { }, { "code": 6026, - "name": "StakeAccountAlreadyFunded", + "name": "StakeAccountIsFundedToSettlement", "msg": "Provided stake account has been already funded to a settlement" }, { @@ -3015,7 +3022,7 @@ export type ValidatorBonds = { { "code": 6029, "name": "StakeAccountNotBigEnoughToSplit", - "msg": "Stake account is not big enough to split" + "msg": "Stake account is not big enough to be split" }, { "code": 6030, @@ -3099,6 +3106,16 @@ export type ValidatorBonds = { }, { "code": 6046, + "name": "WithdrawRequestAmountTooSmall", + "msg": "Too small non-withdrawn withdraw request amount, cancel and init new one" + }, + { + "code": 6047, + "name": "WithdrawRequestAlreadyFulfilled", + "msg": "Withdraw request has been already fulfilled" + }, + { + "code": 6048, "name": "NotYetImplemented", "msg": "Not yet implemented" } @@ -3656,6 +3673,14 @@ export const IDL: ValidatorBonds = { "isMut": false, "isSigner": false }, + { + "name": "authority", + "isMut": false, + "isSigner": true, + "docs": [ + "validator vote account node identity or bond authority may claim" + ] + }, { "name": "withdrawRequest", "isMut": true, @@ -3711,11 +3736,10 @@ export const IDL: ValidatorBonds = { }, { "name": "withdrawer", - "isMut": true, + "isMut": false, "isSigner": false, "docs": [ - "This is the account that will be the new owner (withdrawer authority) of the stake account", - "and ultimately it receives the withdrawing funds" + "New owner of the stake account, it will be accounted to the withdrawer authority" ] }, { @@ -6026,8 +6050,8 @@ export const IDL: ValidatorBonds = { }, { "code": 6010, - "name": "InvalidStakeOwner", - "msg": "Stake account's withdrawer does not match with the provided owner" + "name": "WrongStakeAccountWithdrawer", + "msg": "Wrong withdrawer authority of the stake account" }, { "code": 6011, @@ -6106,7 +6130,7 @@ export const IDL: ValidatorBonds = { }, { "code": 6026, - "name": "StakeAccountAlreadyFunded", + "name": "StakeAccountIsFundedToSettlement", "msg": "Provided stake account has been already funded to a settlement" }, { @@ -6122,7 +6146,7 @@ export const IDL: ValidatorBonds = { { "code": 6029, "name": "StakeAccountNotBigEnoughToSplit", - "msg": "Stake account is not big enough to split" + "msg": "Stake account is not big enough to be split" }, { "code": 6030, @@ -6206,6 +6230,16 @@ export const IDL: ValidatorBonds = { }, { "code": 6046, + "name": "WithdrawRequestAmountTooSmall", + "msg": "Too small non-withdrawn withdraw request amount, cancel and init new one" + }, + { + "code": 6047, + "name": "WithdrawRequestAlreadyFulfilled", + "msg": "Withdraw request has been already fulfilled" + }, + { + "code": 6048, "name": "NotYetImplemented", "msg": "Not yet implemented" } diff --git a/packages/validator-bonds-sdk/package.json b/packages/validator-bonds-sdk/package.json index b9d2fe43..b36b3d9e 100644 --- a/packages/validator-bonds-sdk/package.json +++ b/packages/validator-bonds-sdk/package.json @@ -28,10 +28,10 @@ ], "devDependencies": { "@coral-xyz/anchor": "^0.29.0", - "@marinade.finance/anchor-common": "^2.1.0", + "@marinade.finance/anchor-common": "^2.1.1", "@marinade.finance/marinade-ts-sdk": "^5.0.6", - "@marinade.finance/ts-common": "^2.1.0", - "@marinade.finance/web3js-common": "^2.1.0", + "@marinade.finance/ts-common": "^2.1.1", + "@marinade.finance/web3js-common": "^2.1.1", "@solana/buffer-layout": "^4.0.1", "@solana/web3.js": "^1.87.6", "anchor-bankrun": "^0.3.0", diff --git a/packages/validator-bonds-sdk/src/instructions/claimWithdrawRequest.ts b/packages/validator-bonds-sdk/src/instructions/claimWithdrawRequest.ts index 7e282e54..cd256299 100644 --- a/packages/validator-bonds-sdk/src/instructions/claimWithdrawRequest.ts +++ b/packages/validator-bonds-sdk/src/instructions/claimWithdrawRequest.ts @@ -24,6 +24,7 @@ export async function claimWithdrawRequestInstruction({ configAccount, validatorVoteAccount, stakeAccount, + authority = anchorProgramWalletPubkey(program), splitStakeRentPayer = anchorProgramWalletPubkey(program), withdrawer, }: { @@ -33,6 +34,7 @@ export async function claimWithdrawRequestInstruction({ configAccount?: PublicKey validatorVoteAccount?: PublicKey stakeAccount: PublicKey + authority?: PublicKey | Keypair | Signer | WalletInterface // signer splitStakeRentPayer?: PublicKey | Keypair | Signer | WalletInterface // signer withdrawer?: PublicKey }): Promise<{ @@ -95,6 +97,7 @@ export async function claimWithdrawRequestInstruction({ withdrawer = voteAccountData.account.data.nodePubkey } + authority = authority instanceof PublicKey ? authority : authority.publicKey splitStakeRentPayer = splitStakeRentPayer instanceof PublicKey ? splitStakeRentPayer @@ -111,6 +114,7 @@ export async function claimWithdrawRequestInstruction({ stakeAccount, withdrawer, splitStakeAccount: splitStakeAccount.publicKey, + authority, splitStakeRentPayer, stakeHistory: SYSVAR_STAKE_HISTORY_PUBKEY, stakeProgram: StakeProgram.programId, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3007adaf..6d6a0a41 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.1.0 - version: 2.1.0(@jest/globals@29.7.0)(@solana/web3.js@1.87.6)(bn.js@5.2.1)(jest-shell-matchers@1.0.2) + specifier: ^2.1.1 + version: 2.1.1(@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,23 +45,23 @@ importers: specifier: ^0.29.0 version: 0.29.0 '@marinade.finance/anchor-common': - specifier: ^2.1.0 - version: 2.1.0(@coral-xyz/anchor@0.29.0)(@marinade.finance/ts-common@2.1.0)(@solana/web3.js@1.87.6)(bn.js@5.2.1) + specifier: ^2.1.1 + version: 2.1.1(@coral-xyz/anchor@0.29.0)(@marinade.finance/ts-common@2.1.1)(@solana/web3.js@1.87.6)(bn.js@5.2.1) '@marinade.finance/cli-common': - specifier: ^2.1.0 - version: 2.1.0(@marinade.finance/ledger-utils@2.1.0)(@marinade.finance/ts-common@2.1.0)(@marinade.finance/web3js-common@2.1.0)(@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.1.1 + version: 2.1.1(@marinade.finance/ledger-utils@2.1.1)(@marinade.finance/ts-common@2.1.1)(@marinade.finance/web3js-common@2.1.1)(@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/ledger-utils': - specifier: ^2.1.0 - version: 2.1.0(@ledgerhq/errors@6.16.1)(@ledgerhq/hw-app-solana@7.1.1)(@ledgerhq/hw-transport-node-hid-noevents@6.29.1)(@marinade.finance/ts-common@2.1.0)(@solana/web3.js@1.87.6) + specifier: ^2.1.1 + version: 2.1.1(@ledgerhq/errors@6.16.1)(@ledgerhq/hw-app-solana@7.1.1)(@ledgerhq/hw-transport-node-hid-noevents@6.29.1)(@marinade.finance/ts-common@2.1.1)(@solana/web3.js@1.87.6) '@marinade.finance/ts-common': - specifier: ^2.1.0 - version: 2.1.0 + specifier: ^2.1.1 + version: 2.1.1 '@marinade.finance/validator-bonds-sdk': specifier: ^1.1.5 version: link:../validator-bonds-sdk '@marinade.finance/web3js-common': - specifier: ^2.1.0 - version: 2.1.0(@marinade.finance/ts-common@2.1.0)(@solana/web3.js@1.87.6)(bn.js@5.2.1)(borsh@0.7.0)(bs58@5.0.0) + specifier: ^2.1.1 + version: 2.1.1(@marinade.finance/ts-common@2.1.1)(@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 @@ -88,8 +88,8 @@ importers: version: 2.3.3 devDependencies: '@marinade.finance/jest-utils': - specifier: ^2.1.0 - version: 2.1.0(@jest/globals@29.7.0)(@solana/web3.js@1.87.6)(bn.js@5.2.1)(jest-shell-matchers@1.0.2) + specifier: ^2.1.1 + version: 2.1.1(@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: @@ -101,17 +101,17 @@ importers: specifier: ^0.29.0 version: 0.29.0 '@marinade.finance/anchor-common': - specifier: ^2.1.0 - version: 2.1.0(@coral-xyz/anchor@0.29.0)(@marinade.finance/ts-common@2.1.0)(@solana/web3.js@1.87.6)(bn.js@5.2.1) + specifier: ^2.1.1 + version: 2.1.1(@coral-xyz/anchor@0.29.0)(@marinade.finance/ts-common@2.1.1)(@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.1.0 - version: 2.1.0 + specifier: ^2.1.1 + version: 2.1.1 '@marinade.finance/web3js-common': - specifier: ^2.1.0 - version: 2.1.0(@marinade.finance/ts-common@2.1.0)(@solana/web3.js@1.87.6)(bn.js@5.2.1)(borsh@0.7.0)(bs58@5.0.0) + specifier: ^2.1.1 + version: 2.1.1(@marinade.finance/ts-common@2.1.1)(@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 @@ -967,26 +967,26 @@ packages: resolution: {integrity: sha512-ExDoj1QV5eC6TEbMdLUMMk9cfvNKhhv5gXol4SmULRVCx/3iyCPhJ74nsb3S0Vb+/f+XujBEj3vQn5+cwS0fNA==} dev: false - /@marinade.finance/anchor-common@2.1.0(@coral-xyz/anchor@0.29.0)(@marinade.finance/ts-common@2.1.0)(@solana/web3.js@1.87.6)(bn.js@5.2.1): - resolution: {integrity: sha512-mey7bCN3sj5TNT/DHbww/6Ml3p5tm2YPPsXQMa/KNGM1mslrzQjFeR/AR9IoSgBWNfJ5AiUPW3Kn7mocEy5oOg==} + /@marinade.finance/anchor-common@2.1.1(@coral-xyz/anchor@0.29.0)(@marinade.finance/ts-common@2.1.1)(@solana/web3.js@1.87.6)(bn.js@5.2.1): + resolution: {integrity: sha512-XzHXXobwao7tuVOYVAw4noEGfeeRb099KVzBVIcScvqxKAzQuNoAH2qItRwi874LKRm+ad7LpX5JltJgtfArtQ==} peerDependencies: '@coral-xyz/anchor': ^0.28.0 || 0.29 - '@marinade.finance/ts-common': ^2.1.0 + '@marinade.finance/ts-common': ^2.1.1 '@solana/web3.js': ^1.78.5 bn.js: ^5.2.1 dependencies: '@coral-xyz/anchor': 0.29.0 - '@marinade.finance/ts-common': 2.1.0 + '@marinade.finance/ts-common': 2.1.1 '@solana/web3.js': 1.87.6 bn.js: 5.2.1 - /@marinade.finance/cli-common@2.1.0(@marinade.finance/ledger-utils@2.1.0)(@marinade.finance/ts-common@2.1.0)(@marinade.finance/web3js-common@2.1.0)(@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-y1BX3o5f6D2VCNLtq1wRst/PaSz+4CWiVMWVkVzA9hgsqy57lN/15VkLSyMzmpnn48hEMZmRP0qC/o9288n6Fw==} + /@marinade.finance/cli-common@2.1.1(@marinade.finance/ledger-utils@2.1.1)(@marinade.finance/ts-common@2.1.1)(@marinade.finance/web3js-common@2.1.1)(@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-DOOdpy3Yuato9xxMlAzHqPOMf+cRGTir4CaC/a+DD7ZjpNVlDOB3G/YDy9pw05zcCFnLKTF29TuxDxeYtJW7CQ==} engines: {node: '>=16.0.0'} peerDependencies: - '@marinade.finance/ledger-utils': ^2.1.0 - '@marinade.finance/ts-common': ^2.1.0 - '@marinade.finance/web3js-common': ^2.1.0 + '@marinade.finance/ledger-utils': ^2.1.1 + '@marinade.finance/ts-common': ^2.1.1 + '@marinade.finance/web3js-common': ^2.1.1 '@solana/web3.js': ^1.78.5 bn.js: ^5.2.1 borsh: ^0.7.0 @@ -995,9 +995,9 @@ packages: pino: ^8.15.1 yaml: ^2.3.2 dependencies: - '@marinade.finance/ledger-utils': 2.1.0(@ledgerhq/errors@6.16.1)(@ledgerhq/hw-app-solana@7.1.1)(@ledgerhq/hw-transport-node-hid-noevents@6.29.1)(@marinade.finance/ts-common@2.1.0)(@solana/web3.js@1.87.6) - '@marinade.finance/ts-common': 2.1.0 - '@marinade.finance/web3js-common': 2.1.0(@marinade.finance/ts-common@2.1.0)(@solana/web3.js@1.87.6)(bn.js@5.2.1)(borsh@0.7.0)(bs58@5.0.0) + '@marinade.finance/ledger-utils': 2.1.1(@ledgerhq/errors@6.16.1)(@ledgerhq/hw-app-solana@7.1.1)(@ledgerhq/hw-transport-node-hid-noevents@6.29.1)(@marinade.finance/ts-common@2.1.1)(@solana/web3.js@1.87.6) + '@marinade.finance/ts-common': 2.1.1 + '@marinade.finance/web3js-common': 2.1.1(@marinade.finance/ts-common@2.1.1)(@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 @@ -1026,8 +1026,8 @@ packages: - utf-8-validate dev: true - /@marinade.finance/jest-utils@2.1.0(@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-7X5xxehCsad1Oi7BpGL3uPfS+CyXCjrx1SWY5cETdylkLWq4SE8Dc8y16dRy1lyAGYaZbM01KuYl6mvEdghvzA==} + /@marinade.finance/jest-utils@2.1.1(@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-uIzQlvhEb4jcH8HGvn63l6bYfGLuP2Rxk8PqHb+im1oNAlqQ49ZdnmtMzSLrg9szZIvYRGcPPYiR+2/rrrsVbA==} peerDependencies: '@jest/globals': ^29.5.0 '@solana/web3.js': ^1.78.4 @@ -1040,19 +1040,19 @@ packages: jest-shell-matchers: 1.0.2(jest@29.7.0) dev: true - /@marinade.finance/ledger-utils@2.1.0(@ledgerhq/errors@6.16.1)(@ledgerhq/hw-app-solana@7.1.1)(@ledgerhq/hw-transport-node-hid-noevents@6.29.1)(@marinade.finance/ts-common@2.1.0)(@solana/web3.js@1.87.6): - resolution: {integrity: sha512-xmoqp2ty6Tgpai4S+WFBHFS0ymh4eTq0VtVKV0H3JP+YKsGZOJdViAOSqYSf9h1JNTKqOvWZmgwzgFI2RcZUtQ==} + /@marinade.finance/ledger-utils@2.1.1(@ledgerhq/errors@6.16.1)(@ledgerhq/hw-app-solana@7.1.1)(@ledgerhq/hw-transport-node-hid-noevents@6.29.1)(@marinade.finance/ts-common@2.1.1)(@solana/web3.js@1.87.6): + resolution: {integrity: sha512-HAUshLGREk58yC+5sNmoRXx40sZuuSHt3/b5T1YIVI5QWWTnB9uUQMdGCa92t19IsQz8f8Ou25DoXKNCSj8Trw==} peerDependencies: '@ledgerhq/errors': ^6.14.0 '@ledgerhq/hw-app-solana': ^7.0.13 '@ledgerhq/hw-transport-node-hid-noevents': ^6.27.19 - '@marinade.finance/ts-common': ^2.1.0 + '@marinade.finance/ts-common': ^2.1.1 '@solana/web3.js': ^1.78.4 dependencies: '@ledgerhq/errors': 6.16.1 '@ledgerhq/hw-app-solana': 7.1.1 '@ledgerhq/hw-transport-node-hid-noevents': 6.29.1 - '@marinade.finance/ts-common': 2.1.0 + '@marinade.finance/ts-common': 2.1.1 '@solana/web3.js': 1.87.6 dev: false @@ -1088,20 +1088,20 @@ packages: - utf-8-validate dev: true - /@marinade.finance/ts-common@2.1.0: - resolution: {integrity: sha512-/g+V2iLpyVPNr22Se38tHmaw3oOl5/JFlIcnH8ZZVDBfkbismfNoWuzMrAZbhoSDLVtyK1OsE6mTKysq3slKzw==} + /@marinade.finance/ts-common@2.1.1: + resolution: {integrity: sha512-yQdi5jeWC2fzARAi0jhS9/jKaovFtz6tqqwxVAoZ0e/dTYsdx0Kbpo9jznmPPeykKVff6vf3F6Ww4ZuVCEUuTw==} - /@marinade.finance/web3js-common@2.1.0(@marinade.finance/ts-common@2.1.0)(@solana/web3.js@1.87.6)(bn.js@5.2.1)(borsh@0.7.0)(bs58@5.0.0): - resolution: {integrity: sha512-OaaQc2W72XFwaeBsJGu8gTzOzh9xDk8lfcyPOmw12rNTefbFMfvkrUVrEqj6crEWNzxAMmlSrtl4DATjXaMVDw==} + /@marinade.finance/web3js-common@2.1.1(@marinade.finance/ts-common@2.1.1)(@solana/web3.js@1.87.6)(bn.js@5.2.1)(borsh@0.7.0)(bs58@5.0.0): + resolution: {integrity: sha512-zjPfLF61eBign4WRiVA+hE4gMWZCZUEvFF7RVKCP0hMw/iLSnSEplFq89l0qM0Bq7lodvtYg/Hjlf+ElL1a7bw==} engines: {node: '>=16.0.0'} peerDependencies: - '@marinade.finance/ts-common': 2.1.0 + '@marinade.finance/ts-common': 2.1.1 '@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.1.0 + '@marinade.finance/ts-common': 2.1.1 '@solana/web3.js': 1.87.6 bn.js: 5.2.1 borsh: 0.7.0 diff --git a/programs/validator-bonds/src/checks.rs b/programs/validator-bonds/src/checks.rs index 7cb7b786..3636a7ee 100644 --- a/programs/validator-bonds/src/checks.rs +++ b/programs/validator-bonds/src/checks.rs @@ -106,7 +106,7 @@ pub fn check_stake_is_initialized_with_withdrawer_authority( error!(ErrorCode::UninitializedStake).with_account_name(stake_account_attribute_name), )?; if stake_meta.authorized.withdrawer != *authority { - return Err(error!(ErrorCode::InvalidStakeOwner) + return Err(error!(ErrorCode::WrongStakeAccountWithdrawer) .with_account_name(stake_account_attribute_name) .with_pubkeys((stake_meta.authorized.withdrawer, *authority))); } @@ -389,7 +389,7 @@ mod tests { &wrong_withdrawer, "" ), - Err(ErrorCode::InvalidStakeOwner.into()) + Err(ErrorCode::WrongStakeAccountWithdrawer.into()) ); } diff --git a/programs/validator-bonds/src/error.rs b/programs/validator-bonds/src/error.rs index 354c3b26..ac729e84 100644 --- a/programs/validator-bonds/src/error.rs +++ b/programs/validator-bonds/src/error.rs @@ -32,8 +32,8 @@ pub enum ErrorCode { #[msg("Fail to create program address for Bond")] InvalidBondAddress, // 6009 0x1779 - #[msg("Stake account's withdrawer does not match with the provided owner")] - InvalidStakeOwner, // 6010 0x177a + #[msg("Wrong withdrawer authority of the stake account")] + WrongStakeAccountWithdrawer, // 6010 0x177a #[msg("Fail to create program address for WithdrawRequest")] InvalidWithdrawRequestAddress, // 6011 0x177b @@ -81,7 +81,7 @@ pub enum ErrorCode { SettlementNotClosed, // 6025 0x1789 #[msg("Provided stake account has been already funded to a settlement")] - StakeAccountAlreadyFunded, // 6026 0x178a + StakeAccountIsFundedToSettlement, // 6026 0x178a #[msg("Settlement claim proof failed")] ClaimSettlementProofFailed, // 6027 0x178b @@ -89,7 +89,7 @@ pub enum ErrorCode { #[msg("Provided stake account is locked-up")] StakeLockedUp, // 6028 0x178c - #[msg("Stake account is not big enough to split")] + #[msg("Stake account is not big enough to be split")] StakeAccountNotBigEnoughToSplit, // 6029 0x178d #[msg("Claiming bigger amount than the max total claim")] @@ -140,6 +140,12 @@ pub enum ErrorCode { #[msg("Delegation of provided stake account mismatches")] StakeDelegationMismatch, // 6045 0x179d + #[msg("Too small non-withdrawn withdraw request amount, cancel and init new one")] + WithdrawRequestAmountTooSmall, // 6046 0x179e + + #[msg("Withdraw request has been already fulfilled")] + WithdrawRequestAlreadyFulfilled, // 6047 0x179f + #[msg("Not yet implemented")] - NotYetImplemented, // 6046 0x179e + NotYetImplemented, // 6048 0x17a0 } diff --git a/programs/validator-bonds/src/instructions/bond/fund_bond.rs b/programs/validator-bonds/src/instructions/bond/fund_bond.rs index 42c23396..f103bb6e 100644 --- a/programs/validator-bonds/src/instructions/bond/fund_bond.rs +++ b/programs/validator-bonds/src/instructions/bond/fund_bond.rs @@ -57,11 +57,28 @@ pub struct FundBond<'info> { impl<'info> FundBond<'info> { pub fn process(&mut self) -> Result<()> { + // when the stake account is already "owned" by the bonds program, let's just return OK + if check_stake_is_initialized_with_withdrawer_authority( + &self.stake_account, + &self.bonds_withdrawer_authority.key(), + "stake_account", + ) + .is_ok() + { + msg!( + "Stake account {} is already owned by the bonds program", + self.stake_account.key() + ); + return Ok(()); + } + + // check we've got signature of the stake account owner check_stake_is_initialized_with_withdrawer_authority( &self.stake_account, &self.stake_authority.key(), "stake_account", )?; + // check the stake account is in valid state to be used for bonds check_stake_is_not_locked(&self.stake_account, &self.clock, "stake_account")?; check_stake_exist_and_fully_activated( &self.stake_account, diff --git a/programs/validator-bonds/src/instructions/settlement/fund_settlement.rs b/programs/validator-bonds/src/instructions/settlement/fund_settlement.rs index a21b6145..f672acfc 100644 --- a/programs/validator-bonds/src/instructions/settlement/fund_settlement.rs +++ b/programs/validator-bonds/src/instructions/settlement/fund_settlement.rs @@ -141,7 +141,7 @@ impl<'info> FundSettlement<'info> { require_keys_eq!( stake_meta.authorized.staker, self.bonds_withdrawer_authority.key(), - ErrorCode::StakeAccountAlreadyFunded, + ErrorCode::StakeAccountIsFundedToSettlement, ); // TODO: consider if missing to check the stake account is fully activated diff --git a/programs/validator-bonds/src/instructions/withdraw/claim_withdraw_request.rs b/programs/validator-bonds/src/instructions/withdraw/claim_withdraw_request.rs index 7e8a23c7..13fc2d50 100644 --- a/programs/validator-bonds/src/instructions/withdraw/claim_withdraw_request.rs +++ b/programs/validator-bonds/src/instructions/withdraw/claim_withdraw_request.rs @@ -1,6 +1,7 @@ use crate::checks::{ - check_stake_is_initialized_with_withdrawer_authority, check_stake_valid_delegation, - check_validator_vote_account_validator_identity, + check_bond_change_permitted, check_stake_exist_and_fully_activated, + check_stake_is_initialized_with_withdrawer_authority, check_stake_is_not_locked, + check_stake_valid_delegation, }; use crate::constants::BONDS_AUTHORITY_SEED; use crate::error::ErrorCode; @@ -12,7 +13,6 @@ use crate::state::withdraw_request::WithdrawRequest; use crate::utils::{minimal_size_stake_account, return_unused_split_stake_account_rent}; use anchor_lang::prelude::*; use anchor_lang::solana_program::stake::state::{StakeAuthorize, StakeState}; -use anchor_lang::solana_program::sysvar::stake_history; use anchor_lang::solana_program::{program::invoke_signed, stake, system_program}; use anchor_spl::stake::{authorize, Authorize, Stake, StakeAccount}; @@ -41,6 +41,10 @@ pub struct ClaimWithdrawRequest<'info> { #[account()] validator_vote_account: UncheckedAccount<'info>, + /// validator vote account node identity or bond authority may claim + #[account()] + authority: Signer<'info>, + #[account( mut, has_one = validator_vote_account @ ErrorCode::WithdrawRequestVoteAccountMismatch, @@ -69,10 +73,9 @@ pub struct ClaimWithdrawRequest<'info> { #[account(mut)] stake_account: Account<'info, StakeAccount>, - /// CHECK: this has to match with validator vote account validator identity. - /// This is the account that will be the new owner (withdrawer authority) of the stake account - /// and ultimately it receives the withdrawing funds - #[account(mut)] + /// CHECK: whatever address, authority signature states his intention to withdraw the funds + /// New owner of the stake account, it will be accounted to the withdrawer authority + #[account()] withdrawer: UncheckedAccount<'info>, /// this is a whatever address that does not exist @@ -97,25 +100,40 @@ pub struct ClaimWithdrawRequest<'info> { system_program: Program<'info, System>, - /// CHECK: stake history address, no parsing not eating CPU cycles - #[account(address = stake_history::ID)] - stake_history: UncheckedAccount<'info>, + stake_history: Sysvar<'info, StakeHistory>, clock: Sysvar<'info, Clock>, } impl<'info> ClaimWithdrawRequest<'info> { pub fn process(&mut self) -> Result<()> { - // vote account validator identity matches the authority where the funds will be withdrawn to - // i.e., this address will be the new owner (withdrawer authority) of the stake account - check_validator_vote_account_validator_identity( - &self.validator_vote_account, - &self.withdrawer.key(), - )?; + require_gt!( + self.withdraw_request + .requested_amount + .saturating_sub(self.withdraw_request.withdrawn_amount), + 0, + ErrorCode::WithdrawRequestAlreadyFulfilled, + ); + // claim is permission-ed as the init withdraw request + require!( + check_bond_change_permitted( + &self.authority.key(), + &self.bond, + &self.validator_vote_account + ), + ErrorCode::InvalidWithdrawRequestAuthority + ); + + // whoever can provide us with the stake account of any configuration, requiring the same constraints as for funding + check_stake_is_not_locked(&self.stake_account, &self.clock, "stake_account")?; + check_stake_exist_and_fully_activated( + &self.stake_account, + self.clock.epoch, + &self.stake_history, + )?; // stake account is delegated to the validator vote account associated with the bond - let stake_delegation = - check_stake_valid_delegation(&self.stake_account, &self.bond.validator_vote_account)?; + check_stake_valid_delegation(&self.stake_account, &self.bond.validator_vote_account)?; // stake account belongs under the bonds program let stake_meta = check_stake_is_initialized_with_withdrawer_authority( @@ -123,11 +141,11 @@ impl<'info> ClaimWithdrawRequest<'info> { &self.bonds_withdrawer_authority.key(), "stake_account", )?; - // stake account is not funded + // stake account is not funded to settlement and is still under bonds program require_keys_eq!( stake_meta.authorized.staker, self.bonds_withdrawer_authority.key(), - ErrorCode::StakeAccountAlreadyFunded, + ErrorCode::StakeAccountIsFundedToSettlement, ); // the amount that has not yet been withdrawn from the request @@ -144,19 +162,26 @@ impl<'info> ClaimWithdrawRequest<'info> { // ensuring that splitting means stake accounts will be big enough // note: the rent exempt of the newly created split account has been already paid by the tx caller let minimal_stake_size = minimal_size_stake_account(&stake_meta, &self.config); - if self.stake_account.get_lamports() - amount_to_fulfill_withdraw < minimal_stake_size - || amount_to_fulfill_withdraw < minimal_stake_size - { + if self.stake_account.get_lamports() - amount_to_fulfill_withdraw < minimal_stake_size { return Err(error!(ErrorCode::StakeAccountNotBigEnoughToSplit) .with_account_name("stake_account") - .with_values(("stake_account_lamports - amount_to_fulfill_withdraw < minimal_stake_size || amount_to_fulfill_withdraw < minimal_stake_size", - format!("{} - {} < {} || {} < {}", - self.stake_account.get_lamports(), - amount_to_fulfill_withdraw, - minimal_stake_size, - amount_to_fulfill_withdraw, - minimal_stake_size, - )))); + .with_values(( + "stake_account_lamports - amount_to_fulfill_withdraw < minimal_stake_size", + format!( + "{} - {} < {}", + self.stake_account.get_lamports(), + amount_to_fulfill_withdraw, + minimal_stake_size, + ), + ))); + } + if amount_to_fulfill_withdraw < minimal_stake_size { + return Err(error!(ErrorCode::WithdrawRequestAmountTooSmall) + .with_account_name("stake_account") + .with_values(( + "amount_to_fulfill_withdraw < minimal_stake_size", + format!("{} < {}", amount_to_fulfill_withdraw, minimal_stake_size,), + ))); } let withdraw_split_leftover = @@ -192,10 +217,10 @@ impl<'info> ClaimWithdrawRequest<'info> { &self.split_stake_account, &self.split_stake_rent_payer, &self.clock, - &self.stake_history, + &self.stake_history.to_account_info(), )?; // withdrawal amount is full stake account - (stake_delegation.stake, false) + (self.stake_account.to_account_info().lamports(), false) }; let old_withdrawn_amount = self.withdraw_request.withdrawn_amount;