Skip to content

Commit 5bc06b4

Browse files
authored
feat: add new recover instruction (#564)
* add feature * test: add tests * test: add tests * don't touch this file * clean * add comment * clean imports * fix typo * fix workflows * clippy * fix idls * update to anza releases * use nightly for the idl * rename to transfer_instruction * continue rename * bump program
1 parent 9a9a0ec commit 5bc06b4

File tree

11 files changed

+489
-33
lines changed

11 files changed

+489
-33
lines changed

.github/workflows/anchor-idl.yml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ jobs:
1111
runs-on: ubuntu-latest
1212

1313
steps:
14+
- uses: actions-rs/toolchain@v1
15+
with:
16+
profile: minimal
17+
toolchain: nightly-2024-02-01
18+
components: rustfmt, clippy
1419
- uses: actions/checkout@v2
1520
- name: Setup Node.js
1621
uses: actions/setup-node@v2
@@ -20,13 +25,15 @@ jobs:
2025
run: npm ci
2126
- name: Install Solana
2227
run: |
23-
sh -c "$(curl -sSfL https://release.solana.com/v1.18.16/install)"
28+
sh -c "$(curl -sSfL https://release.anza.xyz/v1.18.16/install)"
2429
echo "/home/runner/.local/share/solana/install/active_release/bin" >> $GITHUB_PATH
2530
- name: Install Anchor
2631
working-directory: ./staking
2732
run: npm i -g @coral-xyz/[email protected]
2833
- name: Build IDL
2934
working-directory: ./staking
35+
env:
36+
RUSTUP_TOOLCHAIN: nightly-2024-02-01
3037
run: anchor build
3138
- name: Check commited idl is up to date
3239
working-directory: ./staking

.github/workflows/anchor.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ jobs:
2222
run: npm ci
2323
- name: Install Solana
2424
run: |
25-
sh -c "$(curl -sSfL https://release.solana.com/v1.18.16/install)"
25+
sh -c "$(curl -sSfL https://release.anza.xyz/v1.18.16/install)"
2626
echo "/home/runner/.local/share/solana/install/active_release/bin" >> $GITHUB_PATH
2727
- name: Install Solana Verify CLI
2828
run: |

.github/workflows/metrics_deploy.yml

Lines changed: 0 additions & 28 deletions
This file was deleted.

staking/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

staking/integration-tests/src/staking/instructions.rs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,3 +510,70 @@ pub fn merge_target_positions(
510510

511511
svm.send_transaction(tx)
512512
}
513+
514+
pub fn transfer_account(
515+
svm: &mut litesvm::LiteSVM,
516+
governance_authority: &Keypair,
517+
stake_account_positions: Pubkey,
518+
new_owner: Pubkey,
519+
) -> TransactionResult {
520+
let config = get_config_address();
521+
let stake_account_metadata = get_stake_account_metadata_address(stake_account_positions);
522+
let voter_record = get_voter_record_address(stake_account_positions);
523+
524+
let accs = staking::accounts::TransferAccount {
525+
governance_authority: governance_authority.pubkey(),
526+
config,
527+
stake_account_metadata,
528+
stake_account_positions,
529+
voter_record,
530+
new_owner,
531+
};
532+
533+
let ix = Instruction::new_with_bytes(
534+
staking::ID,
535+
&staking::instruction::TransferAccount {}.data(),
536+
accs.to_account_metas(None),
537+
);
538+
let tx = Transaction::new_signed_with_payer(
539+
&[ix],
540+
Some(&governance_authority.pubkey()),
541+
&[&governance_authority],
542+
svm.latest_blockhash(),
543+
);
544+
545+
svm.send_transaction(tx)
546+
}
547+
548+
pub fn create_voter_record(
549+
svm: &mut litesvm::LiteSVM,
550+
payer: &Keypair,
551+
stake_account_positions: Pubkey,
552+
) -> TransactionResult {
553+
let config_account = get_config_address();
554+
let stake_account_metadata = get_stake_account_metadata_address(stake_account_positions);
555+
let voter_record = get_voter_record_address(stake_account_positions);
556+
557+
let accs = staking::accounts::CreateVoterRecord {
558+
payer: payer.pubkey(),
559+
stake_account_positions,
560+
stake_account_metadata,
561+
voter_record,
562+
config: config_account,
563+
system_program: system_program::ID,
564+
};
565+
566+
let ix = Instruction::new_with_bytes(
567+
staking::ID,
568+
&staking::instruction::CreateVoterRecord {}.data(),
569+
accs.to_account_metas(None),
570+
);
571+
let tx = Transaction::new_signed_with_payer(
572+
&[ix],
573+
Some(&payer.pubkey()),
574+
&[&payer],
575+
svm.latest_blockhash(),
576+
);
577+
578+
svm.send_transaction(tx)
579+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
use {
2+
anchor_lang::error::ErrorCode,
3+
integration_tests::{
4+
assert_anchor_program_error,
5+
setup::{
6+
setup,
7+
SetupProps,
8+
SetupResult,
9+
},
10+
solana::utils::{
11+
fetch_account_data,
12+
fetch_positions_account,
13+
},
14+
staking::{
15+
helper_functions::initialize_new_stake_account,
16+
instructions::{
17+
create_position,
18+
create_voter_record,
19+
transfer_account,
20+
},
21+
pda::{
22+
get_stake_account_metadata_address,
23+
get_voter_record_address,
24+
},
25+
},
26+
},
27+
solana_sdk::{
28+
native_token::LAMPORTS_PER_SOL,
29+
signature::Keypair,
30+
signer::Signer,
31+
},
32+
staking::{
33+
error::ErrorCode as StakingError,
34+
state::{
35+
positions::TargetWithParameters,
36+
stake_account::StakeAccountMetadataV2,
37+
voter_weight_record::VoterWeightRecord,
38+
},
39+
},
40+
};
41+
42+
#[test]
43+
fn test_transfer_account() {
44+
let SetupResult {
45+
mut svm,
46+
payer: governance_authority,
47+
pyth_token_mint,
48+
publisher_keypair: _,
49+
pool_data_pubkey: _,
50+
reward_program_authority: _,
51+
maybe_publisher_index: _,
52+
} = setup(SetupProps {
53+
init_config: true,
54+
init_target: true,
55+
init_mint: true,
56+
init_pool_data: true,
57+
init_publishers: true,
58+
reward_amount_override: None,
59+
});
60+
61+
let owner = Keypair::new();
62+
let new_owner = Keypair::new();
63+
64+
svm.airdrop(&owner.pubkey(), LAMPORTS_PER_SOL).unwrap();
65+
svm.airdrop(&new_owner.pubkey(), LAMPORTS_PER_SOL).unwrap();
66+
67+
let stake_account_positions =
68+
initialize_new_stake_account(&mut svm, &owner, &pyth_token_mint, true, true);
69+
// make sure voter record can be created permissionlessly if it doesn't exist
70+
create_voter_record(&mut svm, &new_owner, stake_account_positions).unwrap();
71+
72+
assert_anchor_program_error!(
73+
transfer_account(
74+
&mut svm,
75+
&owner, // governance_authority has to sign
76+
stake_account_positions,
77+
new_owner.pubkey()
78+
),
79+
ErrorCode::ConstraintHasOne,
80+
0
81+
);
82+
83+
transfer_account(
84+
&mut svm,
85+
&governance_authority,
86+
stake_account_positions,
87+
new_owner.pubkey(),
88+
)
89+
.unwrap();
90+
91+
let mut positions_account = fetch_positions_account(&mut svm, &stake_account_positions);
92+
let positions = positions_account.to_dynamic_position_array();
93+
assert_eq!(positions.owner().unwrap(), new_owner.pubkey());
94+
95+
let stake_account_metadata: StakeAccountMetadataV2 = fetch_account_data(
96+
&mut svm,
97+
&get_stake_account_metadata_address(stake_account_positions),
98+
);
99+
assert_eq!(stake_account_metadata.owner, new_owner.pubkey());
100+
101+
let voter_record: VoterWeightRecord =
102+
fetch_account_data(&mut svm, &get_voter_record_address(stake_account_positions));
103+
assert_eq!(voter_record.governing_token_owner, new_owner.pubkey());
104+
105+
// new_owner creates a new position
106+
create_position(
107+
&mut svm,
108+
&new_owner,
109+
stake_account_positions,
110+
TargetWithParameters::Voting,
111+
None,
112+
100,
113+
)
114+
.unwrap();
115+
116+
svm.expire_blockhash();
117+
// now the account can't be recovered
118+
assert_anchor_program_error!(
119+
transfer_account(
120+
&mut svm,
121+
&governance_authority,
122+
stake_account_positions,
123+
new_owner.pubkey()
124+
),
125+
StakingError::RecoverWithStake,
126+
0
127+
);
128+
}

staking/programs/staking/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "pyth-staking-program"
3-
version = "2.0.0"
3+
version = "2.1.0"
44
description = "Created with Anchor"
55
edition = "2018"
66

staking/programs/staking/src/context.rs

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -413,7 +413,6 @@ pub struct AdvanceClock<'info> {
413413

414414
#[derive(Accounts)]
415415
pub struct RecoverAccount<'info> {
416-
// Native payer:
417416
pub governance_authority: Signer<'info>,
418417

419418
// Token account:
@@ -445,6 +444,38 @@ pub struct RecoverAccount<'info> {
445444
pub config: Account<'info, global_config::GlobalConfig>,
446445
}
447446

447+
#[derive(Accounts)]
448+
pub struct TransferAccount<'info> {
449+
pub governance_authority: Signer<'info>,
450+
451+
/// CHECK : A new arbitrary owner provided by the governance_authority
452+
pub new_owner: AccountInfo<'info>,
453+
454+
// Stake program accounts:
455+
#[account(mut)]
456+
pub stake_account_positions: AccountLoader<'info, positions::PositionData>,
457+
458+
#[account(
459+
mut,
460+
seeds = [
461+
STAKE_ACCOUNT_METADATA_SEED.as_bytes(),
462+
stake_account_positions.key().as_ref()
463+
],
464+
bump = stake_account_metadata.metadata_bump,
465+
)]
466+
pub stake_account_metadata: Account<'info, stake_account::StakeAccountMetadataV2>,
467+
468+
#[account(
469+
mut,
470+
seeds = [VOTER_RECORD_SEED.as_bytes(), stake_account_positions.key().as_ref()],
471+
bump = stake_account_metadata.voter_bump
472+
)]
473+
pub voter_record: Account<'info, voter_weight_record::VoterWeightRecord>,
474+
475+
#[account(seeds = [CONFIG_SEED.as_bytes()], bump = config.bump, has_one = governance_authority)]
476+
pub config: Account<'info, global_config::GlobalConfig>,
477+
}
478+
448479
#[derive(Accounts)]
449480
#[instruction(slash_ratio: u64)]
450481
pub struct SlashAccount<'info> {

staking/programs/staking/src/lib.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -794,6 +794,29 @@ pub mod staking {
794794
Ok(())
795795
}
796796

797+
/** Transfers a user's stake account to a new owner provided by the `governance_authority`.
798+
*
799+
* This functionality addresses the scenario where a user doesn't have access to their owner
800+
* key. Only accounts without any staked tokens can be transferred.
801+
*/
802+
pub fn transfer_account(ctx: Context<TransferAccount>) -> Result<()> {
803+
// Check that there aren't any positions (i.e., staked tokens) in the account.
804+
// Transferring accounts with staked tokens might lead to double voting
805+
require!(
806+
ctx.accounts.stake_account_metadata.next_index == 0,
807+
ErrorCode::RecoverWithStake
808+
);
809+
810+
let new_owner = ctx.accounts.new_owner.key();
811+
ctx.accounts.stake_account_metadata.owner = new_owner;
812+
let stake_account_positions =
813+
&mut DynamicPositionArray::load_mut(&ctx.accounts.stake_account_positions)?;
814+
stake_account_positions.set_owner(&new_owner)?;
815+
ctx.accounts.voter_record.governing_token_owner = new_owner;
816+
817+
Ok(())
818+
}
819+
797820
pub fn slash_account(
798821
ctx: Context<SlashAccount>,
799822
// a number between 0 and 1 with 6 decimals of precision

0 commit comments

Comments
 (0)