diff --git a/.github/workflows/anchor-idl.yml b/.github/workflows/anchor-idl.yml index 3ecd8d643..ed34ceac4 100644 --- a/.github/workflows/anchor-idl.yml +++ b/.github/workflows/anchor-idl.yml @@ -11,6 +11,11 @@ jobs: runs-on: ubuntu-latest steps: + - uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: nightly-2024-02-01 + components: rustfmt, clippy - uses: actions/checkout@v2 - name: Setup Node.js uses: actions/setup-node@v2 @@ -20,13 +25,15 @@ jobs: run: npm ci - name: Install Solana run: | - sh -c "$(curl -sSfL https://release.solana.com/v1.18.16/install)" + sh -c "$(curl -sSfL https://release.anza.xyz/v1.18.16/install)" echo "/home/runner/.local/share/solana/install/active_release/bin" >> $GITHUB_PATH - name: Install Anchor working-directory: ./staking run: npm i -g @coral-xyz/anchor-cli@0.30.1 - name: Build IDL working-directory: ./staking + env: + RUSTUP_TOOLCHAIN: nightly-2024-02-01 run: anchor build - name: Check commited idl is up to date working-directory: ./staking diff --git a/.github/workflows/anchor.yml b/.github/workflows/anchor.yml index 6d679dd2c..f082833fa 100644 --- a/.github/workflows/anchor.yml +++ b/.github/workflows/anchor.yml @@ -22,7 +22,7 @@ jobs: run: npm ci - name: Install Solana run: | - sh -c "$(curl -sSfL https://release.solana.com/v1.18.16/install)" + sh -c "$(curl -sSfL https://release.anza.xyz/v1.18.16/install)" echo "/home/runner/.local/share/solana/install/active_release/bin" >> $GITHUB_PATH - name: Install Solana Verify CLI run: | diff --git a/.github/workflows/metrics_deploy.yml b/.github/workflows/metrics_deploy.yml deleted file mode 100644 index d42fc9923..000000000 --- a/.github/workflows/metrics_deploy.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: CI -on: - push: - branches: [main] - pull_request: - branches: [main] - -permissions: - contents: read - id-token: write -jobs: - build-and-push-image: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: aws-actions/configure-aws-credentials@v1 - with: - role-to-assume: arn:aws:iam::192824654885:role/github-actions-ecr - aws-region: eu-west-2 - - uses: aws-actions/amazon-ecr-login@v1 - id: ecr_login - - run: | - docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG ./metrics - docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG - env: - ECR_REGISTRY: ${{ steps.ecr_login.outputs.registry }} - ECR_REPOSITORY: governance-metrics - IMAGE_TAG: ${{ github.sha }} diff --git a/staking/Cargo.lock b/staking/Cargo.lock index d4d30632f..6259e30d7 100644 --- a/staking/Cargo.lock +++ b/staking/Cargo.lock @@ -3076,7 +3076,7 @@ dependencies = [ [[package]] name = "pyth-staking-program" -version = "2.0.0" +version = "2.1.0" dependencies = [ "ahash 0.8.11", "anchor-lang", diff --git a/staking/integration-tests/src/staking/instructions.rs b/staking/integration-tests/src/staking/instructions.rs index 7c48d7876..31619b427 100644 --- a/staking/integration-tests/src/staking/instructions.rs +++ b/staking/integration-tests/src/staking/instructions.rs @@ -510,3 +510,70 @@ pub fn merge_target_positions( svm.send_transaction(tx) } + +pub fn transfer_account( + svm: &mut litesvm::LiteSVM, + governance_authority: &Keypair, + stake_account_positions: Pubkey, + new_owner: Pubkey, +) -> TransactionResult { + let config = get_config_address(); + let stake_account_metadata = get_stake_account_metadata_address(stake_account_positions); + let voter_record = get_voter_record_address(stake_account_positions); + + let accs = staking::accounts::TransferAccount { + governance_authority: governance_authority.pubkey(), + config, + stake_account_metadata, + stake_account_positions, + voter_record, + new_owner, + }; + + let ix = Instruction::new_with_bytes( + staking::ID, + &staking::instruction::TransferAccount {}.data(), + accs.to_account_metas(None), + ); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&governance_authority.pubkey()), + &[&governance_authority], + svm.latest_blockhash(), + ); + + svm.send_transaction(tx) +} + +pub fn create_voter_record( + svm: &mut litesvm::LiteSVM, + payer: &Keypair, + stake_account_positions: Pubkey, +) -> TransactionResult { + let config_account = get_config_address(); + let stake_account_metadata = get_stake_account_metadata_address(stake_account_positions); + let voter_record = get_voter_record_address(stake_account_positions); + + let accs = staking::accounts::CreateVoterRecord { + payer: payer.pubkey(), + stake_account_positions, + stake_account_metadata, + voter_record, + config: config_account, + system_program: system_program::ID, + }; + + let ix = Instruction::new_with_bytes( + staking::ID, + &staking::instruction::CreateVoterRecord {}.data(), + accs.to_account_metas(None), + ); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&payer.pubkey()), + &[&payer], + svm.latest_blockhash(), + ); + + svm.send_transaction(tx) +} diff --git a/staking/integration-tests/tests/transfer_account.rs b/staking/integration-tests/tests/transfer_account.rs new file mode 100644 index 000000000..c4a5a75d4 --- /dev/null +++ b/staking/integration-tests/tests/transfer_account.rs @@ -0,0 +1,128 @@ +use { + anchor_lang::error::ErrorCode, + integration_tests::{ + assert_anchor_program_error, + setup::{ + setup, + SetupProps, + SetupResult, + }, + solana::utils::{ + fetch_account_data, + fetch_positions_account, + }, + staking::{ + helper_functions::initialize_new_stake_account, + instructions::{ + create_position, + create_voter_record, + transfer_account, + }, + pda::{ + get_stake_account_metadata_address, + get_voter_record_address, + }, + }, + }, + solana_sdk::{ + native_token::LAMPORTS_PER_SOL, + signature::Keypair, + signer::Signer, + }, + staking::{ + error::ErrorCode as StakingError, + state::{ + positions::TargetWithParameters, + stake_account::StakeAccountMetadataV2, + voter_weight_record::VoterWeightRecord, + }, + }, +}; + +#[test] +fn test_transfer_account() { + let SetupResult { + mut svm, + payer: governance_authority, + pyth_token_mint, + publisher_keypair: _, + pool_data_pubkey: _, + reward_program_authority: _, + maybe_publisher_index: _, + } = setup(SetupProps { + init_config: true, + init_target: true, + init_mint: true, + init_pool_data: true, + init_publishers: true, + reward_amount_override: None, + }); + + let owner = Keypair::new(); + let new_owner = Keypair::new(); + + svm.airdrop(&owner.pubkey(), LAMPORTS_PER_SOL).unwrap(); + svm.airdrop(&new_owner.pubkey(), LAMPORTS_PER_SOL).unwrap(); + + let stake_account_positions = + initialize_new_stake_account(&mut svm, &owner, &pyth_token_mint, true, true); + // make sure voter record can be created permissionlessly if it doesn't exist + create_voter_record(&mut svm, &new_owner, stake_account_positions).unwrap(); + + assert_anchor_program_error!( + transfer_account( + &mut svm, + &owner, // governance_authority has to sign + stake_account_positions, + new_owner.pubkey() + ), + ErrorCode::ConstraintHasOne, + 0 + ); + + transfer_account( + &mut svm, + &governance_authority, + stake_account_positions, + new_owner.pubkey(), + ) + .unwrap(); + + let mut positions_account = fetch_positions_account(&mut svm, &stake_account_positions); + let positions = positions_account.to_dynamic_position_array(); + assert_eq!(positions.owner().unwrap(), new_owner.pubkey()); + + let stake_account_metadata: StakeAccountMetadataV2 = fetch_account_data( + &mut svm, + &get_stake_account_metadata_address(stake_account_positions), + ); + assert_eq!(stake_account_metadata.owner, new_owner.pubkey()); + + let voter_record: VoterWeightRecord = + fetch_account_data(&mut svm, &get_voter_record_address(stake_account_positions)); + assert_eq!(voter_record.governing_token_owner, new_owner.pubkey()); + + // new_owner creates a new position + create_position( + &mut svm, + &new_owner, + stake_account_positions, + TargetWithParameters::Voting, + None, + 100, + ) + .unwrap(); + + svm.expire_blockhash(); + // now the account can't be recovered + assert_anchor_program_error!( + transfer_account( + &mut svm, + &governance_authority, + stake_account_positions, + new_owner.pubkey() + ), + StakingError::RecoverWithStake, + 0 + ); +} diff --git a/staking/programs/staking/Cargo.toml b/staking/programs/staking/Cargo.toml index 57c8dd660..486b58eec 100644 --- a/staking/programs/staking/Cargo.toml +++ b/staking/programs/staking/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pyth-staking-program" -version = "2.0.0" +version = "2.1.0" description = "Created with Anchor" edition = "2018" diff --git a/staking/programs/staking/src/context.rs b/staking/programs/staking/src/context.rs index 0e69ec80e..cc11fe178 100644 --- a/staking/programs/staking/src/context.rs +++ b/staking/programs/staking/src/context.rs @@ -413,7 +413,6 @@ pub struct AdvanceClock<'info> { #[derive(Accounts)] pub struct RecoverAccount<'info> { - // Native payer: pub governance_authority: Signer<'info>, // Token account: @@ -445,6 +444,38 @@ pub struct RecoverAccount<'info> { pub config: Account<'info, global_config::GlobalConfig>, } +#[derive(Accounts)] +pub struct TransferAccount<'info> { + pub governance_authority: Signer<'info>, + + /// CHECK : A new arbitrary owner provided by the governance_authority + pub new_owner: AccountInfo<'info>, + + // Stake program accounts: + #[account(mut)] + pub stake_account_positions: AccountLoader<'info, positions::PositionData>, + + #[account( + mut, + seeds = [ + STAKE_ACCOUNT_METADATA_SEED.as_bytes(), + stake_account_positions.key().as_ref() + ], + bump = stake_account_metadata.metadata_bump, + )] + pub stake_account_metadata: Account<'info, stake_account::StakeAccountMetadataV2>, + + #[account( + mut, + seeds = [VOTER_RECORD_SEED.as_bytes(), stake_account_positions.key().as_ref()], + bump = stake_account_metadata.voter_bump + )] + pub voter_record: Account<'info, voter_weight_record::VoterWeightRecord>, + + #[account(seeds = [CONFIG_SEED.as_bytes()], bump = config.bump, has_one = governance_authority)] + pub config: Account<'info, global_config::GlobalConfig>, +} + #[derive(Accounts)] #[instruction(slash_ratio: u64)] pub struct SlashAccount<'info> { diff --git a/staking/programs/staking/src/lib.rs b/staking/programs/staking/src/lib.rs index c2412cea4..2ea581226 100644 --- a/staking/programs/staking/src/lib.rs +++ b/staking/programs/staking/src/lib.rs @@ -794,6 +794,29 @@ pub mod staking { Ok(()) } + /** Transfers a user's stake account to a new owner provided by the `governance_authority`. + * + * This functionality addresses the scenario where a user doesn't have access to their owner + * key. Only accounts without any staked tokens can be transferred. + */ + pub fn transfer_account(ctx: Context) -> Result<()> { + // Check that there aren't any positions (i.e., staked tokens) in the account. + // Transferring accounts with staked tokens might lead to double voting + require!( + ctx.accounts.stake_account_metadata.next_index == 0, + ErrorCode::RecoverWithStake + ); + + let new_owner = ctx.accounts.new_owner.key(); + ctx.accounts.stake_account_metadata.owner = new_owner; + let stake_account_positions = + &mut DynamicPositionArray::load_mut(&ctx.accounts.stake_account_positions)?; + stake_account_positions.set_owner(&new_owner)?; + ctx.accounts.voter_record.governing_token_owner = new_owner; + + Ok(()) + } + pub fn slash_account( ctx: Context, // a number between 0 and 1 with 6 decimals of precision diff --git a/staking/target/idl/staking.json b/staking/target/idl/staking.json index 4c2c40476..f8bef47d3 100644 --- a/staking/target/idl/staking.json +++ b/staking/target/idl/staking.json @@ -1693,6 +1693,120 @@ } ] }, + { + "name": "transfer_account", + "docs": [ + "Transfers a user's stake account to a new owner provided by the `governance_authority`.\n *\n * This functionality addresses the scenario where a user doesn't have access to their owner\n * key. Only accounts without any staked tokens can be transferred." + ], + "discriminator": [ + 219, + 120, + 55, + 105, + 3, + 139, + 205, + 6 + ], + "accounts": [ + { + "name": "governance_authority", + "signer": true, + "relations": [ + "config" + ] + }, + { + "name": "new_owner", + "docs": [ + "CHECK : A new arbitrary owner provided by the governance_authority" + ] + }, + { + "name": "stake_account_positions", + "writable": true + }, + { + "name": "stake_account_metadata", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 115, + 116, + 97, + 107, + 101, + 95, + 109, + 101, + 116, + 97, + 100, + 97, + 116, + 97 + ] + }, + { + "kind": "account", + "path": "stake_account_positions" + } + ] + } + }, + { + "name": "voter_record", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 118, + 111, + 116, + 101, + 114, + 95, + 119, + 101, + 105, + 103, + 104, + 116 + ] + }, + { + "kind": "account", + "path": "stake_account_positions" + } + ] + } + }, + { + "name": "config", + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 99, + 111, + 110, + 102, + 105, + 103 + ] + } + ] + } + } + ], + "args": [] + }, { "name": "update_agreement_hash", "discriminator": [ diff --git a/staking/target/types/staking.ts b/staking/target/types/staking.ts index a4c70c29a..4cbd1165d 100644 --- a/staking/target/types/staking.ts +++ b/staking/target/types/staking.ts @@ -1699,6 +1699,120 @@ export type Staking = { } ] }, + { + "name": "transferAccount", + "docs": [ + "Transfers a user's stake account to a new owner provided by the `governance_authority`.\n *\n * This functionality addresses the scenario where a user doesn't have access to their owner\n * key. Only accounts without any staked tokens can be transferred." + ], + "discriminator": [ + 219, + 120, + 55, + 105, + 3, + 139, + 205, + 6 + ], + "accounts": [ + { + "name": "governanceAuthority", + "signer": true, + "relations": [ + "config" + ] + }, + { + "name": "newOwner", + "docs": [ + "CHECK : A new arbitrary owner provided by the governance_authority" + ] + }, + { + "name": "stakeAccountPositions", + "writable": true + }, + { + "name": "stakeAccountMetadata", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 115, + 116, + 97, + 107, + 101, + 95, + 109, + 101, + 116, + 97, + 100, + 97, + 116, + 97 + ] + }, + { + "kind": "account", + "path": "stakeAccountPositions" + } + ] + } + }, + { + "name": "voterRecord", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 118, + 111, + 116, + 101, + 114, + 95, + 119, + 101, + 105, + 103, + 104, + 116 + ] + }, + { + "kind": "account", + "path": "stakeAccountPositions" + } + ] + } + }, + { + "name": "config", + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 99, + 111, + 110, + 102, + 105, + 103 + ] + } + ] + } + } + ], + "args": [] + }, { "name": "updateAgreementHash", "discriminator": [