diff --git a/CHANGELOG.md b/CHANGELOG.md index 3382ab0c6f..d01c7b8c45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ All notable changes to this project will be documented in this file. - `sdk/shreds/go` carries the feed subscription program's `FeedDistribution` account: how much USDC one feed collected for one calendar month. The program is a second program alongside shred subscription and had nothing in this SDK, so each consumer decoded the account at fixed byte offsets itself, lake included. The account is a bytemuck Pod read here field by field, which agrees with the Pod bytes only because the field order leaves no interior padding; `TestStructSizes` pins the 120-byte total and a new test pins every field against a real mainnet account. `Client` is built around one program ID and so gains no fetch method, and `DeserializeFeedDistribution` is exported for a caller that makes its own `getProgramAccounts` call. `make sdk-test` never ran `./sdk/shreds/go/...`, so this package's layout pins have never run in CI; it runs them now. (#4216) - The TypeScript and Python `Feed` deserializers read the RFC-28 tail and synthesize `Active` for an account that carries no status byte, matching the Rust program. New `feed_legacy` fixture covers that path alongside the updated `feed` fixture. - Solana programs (`solana/`) + - `builder-stake` carries its own instruction builders in `instruction::builders`, moved out of the test harness now that a caller outside the crate needs them. Each one fixes the account order the processor expects, so a change there has one place to update rather than one per caller. - `builder-stake` exposes its `processor` module and `try_process_instruction` under the existing `entrypoint` feature, so a test in another crate can load the program natively through `processor!` rather than building it to BPF first. `doublezero-serviceability` already exposes its own for the same reason. The crate's own tests still run against the `.so`, and nothing is exposed to a consumer that does not ask for the feature. - `builder-stake` holds a bond for six months and returns the excess after. The first bond starts the hold and a later one does not restart it, so a repricing that forces a top-up cannot push a builder's withdrawal date out. `Withdraw` returns anything above the stake's requirement to a token account the builder names, and refuses both before the hold elapses and below the requirement, which is what stops a builder walking its bond out from under a live feed. The hold is 180 days rather than calendar months, because a month has no fixed length and the alternative is calendar arithmetic onchain to move a one-off boundary by at most three days. A stake that has never been bonded has no hold, and a zero expiry means the hold has not started rather than that it ended in 1970. Every instruction taking a stake checks the account is at the address its own fields derive, which the zero-copy reader does not do. A `SetHoldExpiry` instruction lets a devnet demo show a withdrawal without waiting: it exists in every build and refuses outside a `development` one, rather than sitting behind a `#[cfg]` that would give the two binaries different instruction encodings for the same bytes. - New `builder-stake` program at `dzbschFChpPoWihZFdnYjyzHJicZwPHb6QTntHjhLki`, holding the 2Z bond a builder posts before deploying a feed under RFC-28. A `BuilderStake` PDA, a 2Z token account owned by it, and `InitializeProgram`, `SetAdmin`, `ConfigureProgram`, `InitializeBuilderStake` and `PostBond`. A bond rather than a deposit: it is returnable after the hold and forfeitable by slashing, and `deposit` carries neither. The address is keyed on `(builder, stake_index)` rather than the builder alone, because RFC-28 collateralizes each feed on its own bond and a builder-only address would cap a builder at one stake for life. The program starts paused, so a deployment with no admin and no tier table holds nothing. No slash instruction yet: the burn authority is what makes this its own deployable, and writing it before the verdict signer is settled means writing it twice. Bond sizing and the six-month hold are not here either. diff --git a/solana/programs/builder-stake/src/instruction/builders.rs b/solana/programs/builder-stake/src/instruction/builders.rs new file mode 100644 index 0000000000..222a885a47 --- /dev/null +++ b/solana/programs/builder-stake/src/instruction/builders.rs @@ -0,0 +1,172 @@ +//! Instruction builders for `builder-stake`. +//! +//! Every builder here fixes the account order the processor expects, so a caller cannot get it +//! wrong and a change to the processor has one place to update rather than one per caller. + +use doublezero_program_tools::get_program_data_address; +use solana_instruction::{AccountMeta, Instruction}; +use solana_pubkey::Pubkey; +use solana_system_interface::program as system_program; + +use crate::{ + instruction::BuilderStakeInstructionData, + state::{self, BuilderStake, ProgramConfig}, + DOUBLEZERO_MINT_KEY, ID, +}; + +fn encode(data: &BuilderStakeInstructionData) -> Vec { + borsh::to_vec(data).unwrap() +} + +pub fn initialize_program(payer: &Pubkey) -> Instruction { + Instruction { + program_id: ID, + accounts: vec![ + AccountMeta::new(*payer, true), + AccountMeta::new(ProgramConfig::find_address().0, false), + AccountMeta::new_readonly(system_program::ID, false), + ], + data: encode(&BuilderStakeInstructionData::InitializeProgram), + } +} + +/// Set the program admin. +/// +/// Two keys, because the processor reads two: `upgrade_authority` signs and is checked against the +/// program data account, and `admin_key` is the value written to the config. They are the same key +/// in these tests and need not be in production, so a single parameter would make the ordinary +/// case inexpressible. +pub fn set_admin(upgrade_authority: &Pubkey, admin_key: &Pubkey) -> Instruction { + Instruction { + program_id: ID, + accounts: vec![ + AccountMeta::new_readonly(get_program_data_address(&ID).0, false), + AccountMeta::new_readonly(*upgrade_authority, true), + AccountMeta::new(ProgramConfig::find_address().0, false), + ], + data: encode(&BuilderStakeInstructionData::SetAdmin(*admin_key)), + } +} + +pub fn set_paused(admin: &Pubkey, paused: bool) -> Instruction { + use crate::instruction::{ProgramConfiguration, ProgramFlagConfiguration}; + + Instruction { + program_id: ID, + accounts: vec![ + AccountMeta::new_readonly(*admin, true), + AccountMeta::new(ProgramConfig::find_address().0, false), + ], + data: encode(&BuilderStakeInstructionData::ConfigureProgram( + ProgramConfiguration::Flag(ProgramFlagConfiguration::IsPaused(paused)), + )), + } +} + +pub fn set_tier_parameters( + admin: &Pubkey, + up_to_1gbps_2z_amount: u64, + up_to_5gbps_2z_amount: u64, + unmetered_2z_amount: u64, +) -> Instruction { + use crate::instruction::ProgramConfiguration; + + Instruction { + program_id: ID, + accounts: vec![ + AccountMeta::new_readonly(*admin, true), + AccountMeta::new(ProgramConfig::find_address().0, false), + ], + data: encode(&BuilderStakeInstructionData::ConfigureProgram( + ProgramConfiguration::TierParameters { + up_to_1gbps_2z_amount, + up_to_5gbps_2z_amount, + unmetered_2z_amount, + }, + )), + } +} + +pub fn initialize_builder_stake( + builder: &Pubkey, + stake_index: u64, + committed_rate_bits_per_sec: u64, +) -> Instruction { + let stake_key = BuilderStake::find_address(builder, stake_index).0; + + Instruction { + program_id: ID, + accounts: vec![ + AccountMeta::new_readonly(ProgramConfig::find_address().0, false), + AccountMeta::new(*builder, true), + AccountMeta::new(stake_key, false), + AccountMeta::new(state::find_2z_token_pda_address(&stake_key).0, false), + AccountMeta::new_readonly(DOUBLEZERO_MINT_KEY, false), + AccountMeta::new_readonly(spl_token_interface::ID, false), + AccountMeta::new_readonly(system_program::ID, false), + ], + data: encode(&BuilderStakeInstructionData::InitializeBuilderStake { + stake_index, + committed_rate_bits_per_sec, + }), + } +} + +pub fn post_bond( + builder: &Pubkey, + stake_index: u64, + source_token_account: &Pubkey, + amount: u64, +) -> Instruction { + let stake_key = BuilderStake::find_address(builder, stake_index).0; + + Instruction { + program_id: ID, + accounts: vec![ + AccountMeta::new_readonly(ProgramConfig::find_address().0, false), + AccountMeta::new_readonly(*builder, true), + AccountMeta::new(stake_key, false), + AccountMeta::new(state::find_2z_token_pda_address(&stake_key).0, false), + AccountMeta::new(*source_token_account, false), + AccountMeta::new_readonly(spl_token_interface::ID, false), + ], + data: encode(&BuilderStakeInstructionData::PostBond { amount }), + } +} + +pub fn withdraw( + builder: &Pubkey, + stake_index: u64, + destination_token_account: &Pubkey, + amount: u64, +) -> Instruction { + let stake_key = BuilderStake::find_address(builder, stake_index).0; + + Instruction { + program_id: ID, + accounts: vec![ + AccountMeta::new_readonly(ProgramConfig::find_address().0, false), + AccountMeta::new_readonly(*builder, true), + AccountMeta::new(stake_key, false), + AccountMeta::new(state::find_2z_token_pda_address(&stake_key).0, false), + AccountMeta::new(*destination_token_account, false), + AccountMeta::new_readonly(spl_token_interface::ID, false), + ], + data: encode(&BuilderStakeInstructionData::Withdraw { amount }), + } +} + +/// Move a stake's hold expiry. Development builds only; the test binary is one. +pub fn set_hold_expiry(admin: &Pubkey, builder: &Pubkey, stake_index: u64, at: i64) -> Instruction { + Instruction { + program_id: ID, + accounts: vec![ + AccountMeta::new_readonly(ProgramConfig::find_address().0, false), + AccountMeta::new_readonly(*admin, true), + AccountMeta::new(BuilderStake::find_address(builder, stake_index).0, false), + ], + data: encode(&BuilderStakeInstructionData::SetHoldExpiry { + hold_expires_at: at, + }), + } +} diff --git a/solana/programs/builder-stake/src/instruction/mod.rs b/solana/programs/builder-stake/src/instruction/mod.rs index f1ec069fff..8fed755c7e 100644 --- a/solana/programs/builder-stake/src/instruction/mod.rs +++ b/solana/programs/builder-stake/src/instruction/mod.rs @@ -1,3 +1,5 @@ +pub mod builders; + use borsh::{BorshDeserialize, BorshSerialize}; use solana_pubkey::Pubkey; diff --git a/solana/programs/builder-stake/tests/common/mod.rs b/solana/programs/builder-stake/tests/common/mod.rs index a5adb346c9..c1bed01b70 100644 --- a/solana/programs/builder-stake/tests/common/mod.rs +++ b/solana/programs/builder-stake/tests/common/mod.rs @@ -6,15 +6,14 @@ #![allow(dead_code)] use doublezero_builder_stake::{ - instruction::BuilderStakeInstructionData, - state::{self, BuilderStake, ProgramConfig}, + state::{BuilderStake, ProgramConfig}, DOUBLEZERO_MINT_KEY, ID, }; use solana_loader_v3_interface::{get_program_data_address, state::UpgradeableLoaderState}; use solana_program_test::{ProgramTest, ProgramTestContext}; use solana_sdk::{ account::Account, - instruction::{AccountMeta, Instruction, InstructionError}, + instruction::{Instruction, InstructionError}, program_pack::Pack, pubkey::Pubkey, signature::{Keypair, Signer}, @@ -231,9 +230,12 @@ impl TestSetup { let admin = self.upgrade_authority.pubkey(); let upgrade_authority = self.upgrade_authority.insecure_clone(); - self.send(set_admin(&admin), &[&upgrade_authority]) - .await - .unwrap(); + self.send( + set_admin(&upgrade_authority.pubkey(), &admin), + &[&upgrade_authority], + ) + .await + .unwrap(); } pub async fn read_builder_stake(&mut self, key: &Pubkey) -> BuilderStake { @@ -281,157 +283,6 @@ impl TestSetup { } } -// -// Instruction builders. -// - -fn encode(data: &BuilderStakeInstructionData) -> Vec { - borsh::to_vec(data).unwrap() -} - -pub fn initialize_program(payer: &Pubkey) -> Instruction { - Instruction { - program_id: ID, - accounts: vec![ - AccountMeta::new(*payer, true), - AccountMeta::new(ProgramConfig::find_address().0, false), - AccountMeta::new_readonly(system_program::ID, false), - ], - data: encode(&BuilderStakeInstructionData::InitializeProgram), - } -} - -pub fn set_admin(admin: &Pubkey) -> Instruction { - Instruction { - program_id: ID, - accounts: vec![ - AccountMeta::new_readonly(get_program_data_address(&ID), false), - AccountMeta::new_readonly(*admin, true), - AccountMeta::new(ProgramConfig::find_address().0, false), - ], - data: encode(&BuilderStakeInstructionData::SetAdmin(*admin)), - } -} - -pub fn set_paused(admin: &Pubkey, paused: bool) -> Instruction { - use doublezero_builder_stake::instruction::{ProgramConfiguration, ProgramFlagConfiguration}; - - Instruction { - program_id: ID, - accounts: vec![ - AccountMeta::new_readonly(*admin, true), - AccountMeta::new(ProgramConfig::find_address().0, false), - ], - data: encode(&BuilderStakeInstructionData::ConfigureProgram( - ProgramConfiguration::Flag(ProgramFlagConfiguration::IsPaused(paused)), - )), - } -} - -pub fn set_tier_parameters( - admin: &Pubkey, - up_to_1gbps_2z_amount: u64, - up_to_5gbps_2z_amount: u64, - unmetered_2z_amount: u64, -) -> Instruction { - use doublezero_builder_stake::instruction::ProgramConfiguration; - - Instruction { - program_id: ID, - accounts: vec![ - AccountMeta::new_readonly(*admin, true), - AccountMeta::new(ProgramConfig::find_address().0, false), - ], - data: encode(&BuilderStakeInstructionData::ConfigureProgram( - ProgramConfiguration::TierParameters { - up_to_1gbps_2z_amount, - up_to_5gbps_2z_amount, - unmetered_2z_amount, - }, - )), - } -} - -pub fn initialize_builder_stake( - builder: &Pubkey, - stake_index: u64, - committed_rate_bits_per_sec: u64, -) -> Instruction { - let stake_key = BuilderStake::find_address(builder, stake_index).0; - - Instruction { - program_id: ID, - accounts: vec![ - AccountMeta::new_readonly(ProgramConfig::find_address().0, false), - AccountMeta::new(*builder, true), - AccountMeta::new(stake_key, false), - AccountMeta::new(state::find_2z_token_pda_address(&stake_key).0, false), - AccountMeta::new_readonly(DOUBLEZERO_MINT_KEY, false), - AccountMeta::new_readonly(spl_token_interface::ID, false), - AccountMeta::new_readonly(system_program::ID, false), - ], - data: encode(&BuilderStakeInstructionData::InitializeBuilderStake { - stake_index, - committed_rate_bits_per_sec, - }), - } -} - -pub fn post_bond( - builder: &Pubkey, - stake_index: u64, - source_token_account: &Pubkey, - amount: u64, -) -> Instruction { - let stake_key = BuilderStake::find_address(builder, stake_index).0; - - Instruction { - program_id: ID, - accounts: vec![ - AccountMeta::new_readonly(ProgramConfig::find_address().0, false), - AccountMeta::new_readonly(*builder, true), - AccountMeta::new(stake_key, false), - AccountMeta::new(state::find_2z_token_pda_address(&stake_key).0, false), - AccountMeta::new(*source_token_account, false), - AccountMeta::new_readonly(spl_token_interface::ID, false), - ], - data: encode(&BuilderStakeInstructionData::PostBond { amount }), - } -} - -pub fn withdraw( - builder: &Pubkey, - stake_index: u64, - destination_token_account: &Pubkey, - amount: u64, -) -> Instruction { - let stake_key = BuilderStake::find_address(builder, stake_index).0; - - Instruction { - program_id: ID, - accounts: vec![ - AccountMeta::new_readonly(ProgramConfig::find_address().0, false), - AccountMeta::new_readonly(*builder, true), - AccountMeta::new(stake_key, false), - AccountMeta::new(state::find_2z_token_pda_address(&stake_key).0, false), - AccountMeta::new(*destination_token_account, false), - AccountMeta::new_readonly(spl_token_interface::ID, false), - ], - data: encode(&BuilderStakeInstructionData::Withdraw { amount }), - } -} - -/// Move a stake's hold expiry. Development builds only; the test binary is one. -pub fn set_hold_expiry(admin: &Pubkey, builder: &Pubkey, stake_index: u64, at: i64) -> Instruction { - Instruction { - program_id: ID, - accounts: vec![ - AccountMeta::new_readonly(ProgramConfig::find_address().0, false), - AccountMeta::new_readonly(*admin, true), - AccountMeta::new(BuilderStake::find_address(builder, stake_index).0, false), - ], - data: encode(&BuilderStakeInstructionData::SetHoldExpiry { - hold_expires_at: at, - }), - } -} +// The instruction builders moved into the crate, where a caller outside these tests can reach +// them. Re-exported so the test files that use them read unchanged. +pub use doublezero_builder_stake::instruction::builders::*; diff --git a/solana/programs/builder-stake/tests/post_bond_test.rs b/solana/programs/builder-stake/tests/post_bond_test.rs index b6c2ea14ae..8b9eb4b85d 100644 --- a/solana/programs/builder-stake/tests/post_bond_test.rs +++ b/solana/programs/builder-stake/tests/post_bond_test.rs @@ -4,7 +4,9 @@ mod common; use doublezero_builder_stake::state::{self, BuilderStake, ProgramConfig}; use solana_program_test::tokio; -use solana_sdk::{instruction::InstructionError, program_error::ProgramError, signature::Signer}; +use solana_sdk::{ + instruction::InstructionError, program_error::ProgramError, pubkey::Pubkey, signature::Signer, +}; const ONE_GBPS: u64 = 1_000_000_000; @@ -41,17 +43,36 @@ async fn test_admin_is_set_by_the_upgrade_authority() { // A stranger cannot claim it. let stranger = t.builder.insecure_clone(); let err = t - .send(common::set_admin(&stranger.pubkey()), &[&stranger]) + .send( + common::set_admin(&stranger.pubkey(), &stranger.pubkey()), + &[&stranger], + ) .await .expect_err("a non-upgrade-authority signer should not set the admin"); // The upgrade-authority check reads the program data account, so a wrong signer fails there // rather than at an authority comparison. common::assert_instruction_error(err, InstructionError::InvalidAccountData); - t.send(common::set_admin(&admin), &[&upgrade_authority]) - .await - .unwrap(); + t.send( + common::set_admin(&upgrade_authority.pubkey(), &admin), + &[&upgrade_authority], + ) + .await + .unwrap(); assert_eq!(t.read_program_config().await.admin_key, admin); + + // The admin need not be the upgrade authority. Every call above happens to set them to the + // same key, which is what hid the two roles behind one parameter, so this covers the case + // that a deployment actually wants: hold the upgrade key in cold storage and let a warmer + // key run the program. + let delegate = Pubkey::new_unique(); + t.send( + common::set_admin(&upgrade_authority.pubkey(), &delegate), + &[&upgrade_authority], + ) + .await + .unwrap(); + assert_eq!(t.read_program_config().await.admin_key, delegate); } /// A builder posts a 2Z bond against a committed rate and the stake records what it holds.