From cc822a0e9315632a55aeba7a5f7a6ee0ee16b0f2 Mon Sep 17 00:00:00 2001 From: Ben Marx Date: Wed, 9 Sep 2026 16:42:27 -0700 Subject: [PATCH 1/4] serviceability: halt and resume a feed `FeedStatus` and the gate that reads it landed with the subscriber work, but nothing could move the status. A staked feed sat in `Pending` for life and no feed had a way to stop publishing. Two instructions and the transitions they refuse. Halt goes only from `Active`, resume only from `Halted`, and each refusal says which way it is wrong rather than sharing one error. `Pending` to `Active` is deliberately absent: that step re-reads the stake mirror, so it belongs with the code that does, and leaving it out here keeps the two apart. The feed's own builder may sign either one, which no other feed instruction allows. RFC-28 makes halt the builder's lever and doubles it as a way to rotate the upstream source without redeploying, so a builder that cannot halt its own feed cannot do either. A `FEED_AUTHORITY` or `FOUNDATION` key may sign as well, because a feed whose builder has gone quiet must still be stoppable. --- CHANGELOG.md | 1 + .../src/feed.rs | 40 ++- .../src/entrypoint.rs | 7 +- .../doublezero-serviceability/src/error.rs | 10 +- .../src/instructions.rs | 14 +- .../src/processors/feed/halt.rs | 75 ++++ .../src/processors/feed/mod.rs | 37 ++ .../src/processors/feed/resume.rs | 72 ++++ .../tests/feed_lifecycle_test.rs | 320 ++++++++++++++++++ 9 files changed, 572 insertions(+), 4 deletions(-) create mode 100644 smartcontract/programs/doublezero-serviceability/src/processors/feed/halt.rs create mode 100644 smartcontract/programs/doublezero-serviceability/src/processors/feed/resume.rs create mode 100644 smartcontract/programs/doublezero-serviceability/tests/feed_lifecycle_test.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index f3908111de..7cb5880377 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to this project will be documented in this file. - CI - The Agave toolchain install retries, and a failed one now fails the job. Eight workflow steps across five workflows ran `sh -c "$(curl -sSfL .../install)"` once with no retry, so a transient reset from `release.anza.xyz` killed a job before it ran anything. That form also swallowed a failed fetch: the command substitution comes back empty, `sh -c ""` exits 0, and the step passed having installed nothing. The eight are now one composite action at `.github/actions/solana-toolchain` that fetches and runs as separate steps, checks `solana --version` actually runs, clears partial state between attempts, bounds every wait so a stalled handshake or hung transfer reaches the backoff instead of sitting until the job times out, and backs off. Each caller keeps the version it used before; `solana.yml` and `offchain.local-validator.yml` are on v3.0.12 and the other three on v3.0.4, which is drift worth settling separately. - Serviceability + - `HaltFeed` (variant 120) and `ResumeFeed` (variant 121), so a feed's status can change. `FeedStatus` and the gate that reads it landed earlier, but nothing could move the status, which left a staked feed in `Pending` for life and gave no feed a way to stop publishing. Halt goes only from `Active` and resume only from `Halted`; every other transition is refused by name, `FeedNotHaltable` (124) or `FeedNotResumable` (125). `Pending` to `Active` is deliberately absent, because that step re-reads the stake mirror and belongs with the code that does. The feed's own `builder` may sign either instruction, which no other feed instruction allows: RFC-28 makes halt the builder's lever and doubles it as upstream-source rotation, so a builder that cannot halt its own feed cannot rotate either. A `FEED_AUTHORITY` or `FOUNDATION` key may sign as well, because a feed whose builder has gone quiet must still be stoppable. - `UpdateMulticastGroupRoles` authorizes only roles a user gains. An existing feed subscription no longer needs a direct subscriber allowlist entry when the user adds publishing. (malbeclabs/infra#2596) - `write_stake_mirror` in the instruction crate and `WriteStakeMirrorCommand` in the Rust SDK, so the relayer has a caller-side surface for the instruction A6 added. The command departs from its neighbours in one way: every other one uses `append_payer_permission_account`, which attaches the caller's `Permission` account only when it already exists, because a legacy `GlobalState` key might authorize instead. No legacy key satisfies `STAKE_ORACLE`, so a missing `Permission` account is always fatal here, and the command says so locally rather than sending a transaction that can only come back `NotAllowed`. It checks what `authorize` checks, not just ownership: an account that does not decode, a suspended `Permission`, and one lacking the flag are each refused with the reason named, which matters because suspending a `Permission` is what revoking the relayer's key looks like. - New `WriteStakeMirror` instruction (variant 119) and a `STAKE_ORACLE` permission, which is what a relayer will call to copy a builder's Solana stake onto the DZ ledger. Nothing could write a `StakeMirror` before this. The instruction refuses a write whose `source_slot` is not newer than the stored one: polling makes a repeated write harmless but says nothing about ordering, and a retry carrying an older read could otherwise walk the mirror backwards, so the program enforces it rather than trusting the caller. It also refuses to reassign a mirror to a different builder, since the builder is not a PDA seed, and it carries `feed_key` forward rather than taking it from the caller, because `CreateFeed` writes that to spend the stake and zeroing it would let one bond back two feeds. No legacy GlobalState key maps to `STAKE_ORACLE`, so a holder needs a real `Permission` account even while `require-permission-accounts` is clear. Gated on `allow-staked-feeds` with the rest of RFC-28. `STAKE_ORACLE` is grantable through `doublezero permission set --add stake-oracle`, named by `permission audit` and `bitmask_to_names`, listed in `AUTHORIZE_GATED_FLAGS` and in the Go SDK's flag constants. It is the first flag no legacy `GlobalState` key can satisfy, which the audit's own test now asserts through a `PERMISSION_ONLY_FLAGS` list rather than treating as a gap in the enumeration. diff --git a/crates/doublezero-serviceability-instruction/src/feed.rs b/crates/doublezero-serviceability-instruction/src/feed.rs index 1c1fae978b..54f11a290a 100644 --- a/crates/doublezero-serviceability-instruction/src/feed.rs +++ b/crates/doublezero-serviceability-instruction/src/feed.rs @@ -29,7 +29,10 @@ use crate::common; use doublezero_serviceability::{ instructions::DoubleZeroInstruction, pda::{get_feed_pda, get_globalstate_pda}, - processors::feed::{create::FeedCreateArgs, delete::FeedDeleteArgs, update::FeedUpdateArgs}, + processors::feed::{ + create::FeedCreateArgs, delete::FeedDeleteArgs, halt::FeedHaltArgs, resume::FeedResumeArgs, + update::FeedUpdateArgs, + }, }; use solana_program::{ instruction::{AccountMeta, Instruction}, @@ -91,6 +94,41 @@ pub fn delete_feed( ) } +/// `HaltFeed` (variant 120). Accounts: `[feed, globalstate]`. +/// +/// Stops a feed publishing, reversibly. Unlike the other feed instructions, the feed's own +/// `builder` may sign this one, so a builder can rotate its upstream source without an operator. +/// A `FEED_AUTHORITY` or `FOUNDATION` key can sign it too, which is why this stays on the +/// permission-appending path. +pub fn halt_feed(program_id: &Pubkey, payer: &Pubkey, feed: &Pubkey) -> Instruction { + let (globalstate, _) = get_globalstate_pda(program_id); + common::build_with_permission( + program_id, + DoubleZeroInstruction::HaltFeed(FeedHaltArgs {}), + vec![ + AccountMeta::new(*feed, false), + AccountMeta::new(globalstate, false), + ], + payer, + ) +} + +/// `ResumeFeed` (variant 121). Accounts: `[feed, globalstate]`. +/// +/// Puts a halted feed back to publishing. Signed by the same keys `halt_feed` accepts. +pub fn resume_feed(program_id: &Pubkey, payer: &Pubkey, feed: &Pubkey) -> Instruction { + let (globalstate, _) = get_globalstate_pda(program_id); + common::build_with_permission( + program_id, + DoubleZeroInstruction::ResumeFeed(FeedResumeArgs {}), + vec![ + AccountMeta::new(*feed, false), + AccountMeta::new(globalstate, false), + ], + payer, + ) +} + #[cfg(test)] mod tests { use super::*; diff --git a/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs b/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs index 437758253e..7e536c0fb4 100644 --- a/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs +++ b/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs @@ -38,7 +38,8 @@ use crate::{ suspend::process_suspend_exchange, update::process_update_exchange, }, feed::{ - create::process_create_feed, delete::process_delete_feed, update::process_update_feed, + create::process_create_feed, delete::process_delete_feed, halt::process_halt_feed, + resume::process_resume_feed, update::process_update_feed, }, globalconfig::set::process_set_globalconfig, globalstate::{ @@ -421,6 +422,10 @@ pub fn process_instruction( DoubleZeroInstruction::WriteStakeMirror(value) => { process_write_stake_mirror(program_id, accounts, &value)? } + DoubleZeroInstruction::HaltFeed(value) => process_halt_feed(program_id, accounts, &value)?, + DoubleZeroInstruction::ResumeFeed(value) => { + process_resume_feed(program_id, accounts, &value)? + } DoubleZeroInstruction::UpdateFeed(value) => { process_update_feed(program_id, accounts, &value)? } diff --git a/smartcontract/programs/doublezero-serviceability/src/error.rs b/smartcontract/programs/doublezero-serviceability/src/error.rs index 8e29a5286c..86a77b7fa7 100644 --- a/smartcontract/programs/doublezero-serviceability/src/error.rs +++ b/smartcontract/programs/doublezero-serviceability/src/error.rs @@ -257,6 +257,10 @@ pub enum DoubleZeroError { StakedFeedCannotBeDeleted, // variant 122 #[error("This feed is not publishing, so it admits no new subscribers")] FeedNotActive, // variant 123 + #[error("Only an active feed can be halted")] + FeedNotHaltable, // variant 124 + #[error("Only a halted feed can be resumed")] + FeedNotResumable, // variant 125 } impl From for ProgramError { @@ -386,6 +390,8 @@ impl From for ProgramError { DoubleZeroError::StakeAlreadyBacksFeed => ProgramError::Custom(121), DoubleZeroError::StakedFeedCannotBeDeleted => ProgramError::Custom(122), DoubleZeroError::FeedNotActive => ProgramError::Custom(123), + DoubleZeroError::FeedNotHaltable => ProgramError::Custom(124), + DoubleZeroError::FeedNotResumable => ProgramError::Custom(125), } } } @@ -516,6 +522,8 @@ impl From for DoubleZeroError { 121 => DoubleZeroError::StakeAlreadyBacksFeed, 122 => DoubleZeroError::StakedFeedCannotBeDeleted, 123 => DoubleZeroError::FeedNotActive, + 124 => DoubleZeroError::FeedNotHaltable, + 125 => DoubleZeroError::FeedNotResumable, _ => DoubleZeroError::Custom(e), } } @@ -550,7 +558,7 @@ mod tests { } // EnumIter generates Custom(0) by default, so we explicitly test values - // outside the known variant range (currently 0-123) to ensure the conversion + // outside the known variant range (currently 0-125) to ensure the conversion // logic handles arbitrary custom codes correctly. for code in [1000u32, 100_000, u32::MAX] { let err = DoubleZeroError::Custom(code); diff --git a/smartcontract/programs/doublezero-serviceability/src/instructions.rs b/smartcontract/programs/doublezero-serviceability/src/instructions.rs index 3e66e56f96..93e45baea5 100644 --- a/smartcontract/programs/doublezero-serviceability/src/instructions.rs +++ b/smartcontract/programs/doublezero-serviceability/src/instructions.rs @@ -27,7 +27,10 @@ use crate::processors::{ create::ExchangeCreateArgs, delete::ExchangeDeleteArgs, resume::ExchangeResumeArgs, setdevice::ExchangeSetDeviceArgs, suspend::ExchangeSuspendArgs, update::ExchangeUpdateArgs, }, - feed::{create::FeedCreateArgs, delete::FeedDeleteArgs, update::FeedUpdateArgs}, + feed::{ + create::FeedCreateArgs, delete::FeedDeleteArgs, halt::FeedHaltArgs, resume::FeedResumeArgs, + update::FeedUpdateArgs, + }, globalconfig::set::SetGlobalConfigArgs, globalstate::{ setairdrop::SetAirdropArgs, setauthority::SetAuthorityArgs, @@ -261,6 +264,9 @@ pub enum DoubleZeroInstruction { UnsubscribeFeed(UnsubscribeFeedArgs), // variant 118 WriteStakeMirror(StakeMirrorWriteArgs), // variant 119 + + HaltFeed(FeedHaltArgs), // variant 120 + ResumeFeed(FeedResumeArgs), // variant 121 } impl DoubleZeroInstruction { @@ -414,6 +420,8 @@ impl DoubleZeroInstruction { 119 => Ok(Self::WriteStakeMirror( StakeMirrorWriteArgs::try_from(rest).unwrap(), )), + 120 => Ok(Self::HaltFeed(FeedHaltArgs::try_from(rest).unwrap())), + 121 => Ok(Self::ResumeFeed(FeedResumeArgs::try_from(rest).unwrap())), _ => Err(ProgramError::InvalidInstructionData), } @@ -561,6 +569,8 @@ impl DoubleZeroInstruction { Self::CreateFeed(_) => "CreateFeed".to_string(), // variant 112 Self::WriteStakeMirror(_) => "WriteStakeMirror".to_string(), // variant 119 + Self::HaltFeed(_) => "HaltFeed".to_string(), // variant 120 + Self::ResumeFeed(_) => "ResumeFeed".to_string(), // variant 121 Self::UpdateFeed(_) => "UpdateFeed".to_string(), // variant 113 Self::DeleteFeed(_) => "DeleteFeed".to_string(), // variant 114 Self::SetAccessPassFeeds(_) => "SetAccessPassFeeds".to_string(), // variant 115 @@ -706,6 +716,8 @@ impl DoubleZeroInstruction { Self::CreateFeed(args) => format!("{args:?}"), // variant 112 Self::WriteStakeMirror(args) => format!("{args:?}"), // variant 119 + Self::HaltFeed(args) => format!("{args:?}"), // variant 120 + Self::ResumeFeed(args) => format!("{args:?}"), // variant 121 Self::UpdateFeed(args) => format!("{args:?}"), // variant 113 Self::DeleteFeed(args) => format!("{args:?}"), // variant 114 Self::SetAccessPassFeeds(args) => format!("{args:?}"), // variant 115 diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/feed/halt.rs b/smartcontract/programs/doublezero-serviceability/src/processors/feed/halt.rs new file mode 100644 index 0000000000..e65dbe93e0 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/processors/feed/halt.rs @@ -0,0 +1,75 @@ +use crate::{ + error::DoubleZeroError, + processors::feed::require_feed_writer, + serializer::try_acc_write, + state::{ + feed::{Feed, FeedStatus}, + globalstate::GlobalState, + }, +}; +use borsh::BorshSerialize; +use borsh_incremental::BorshDeserializeIncremental; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + msg, + pubkey::Pubkey, +}; + +#[derive(BorshSerialize, BorshDeserializeIncremental, PartialEq, Debug, Clone, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct FeedHaltArgs {} + +/// Stop a feed publishing, reversibly. +/// +/// Halt is the builder's own lever and doubles as a way to rotate the upstream source without +/// redeploying, so it is reversible and touches neither the stake nor the seats already sold. +/// Retirement is the terminal path and is not this. +pub fn process_halt_feed( + program_id: &Pubkey, + accounts: &[AccountInfo], + _value: &FeedHaltArgs, +) -> ProgramResult { + let accounts_iter = &mut accounts.iter(); + + let feed_account = next_account_info(accounts_iter)?; + let globalstate_account = next_account_info(accounts_iter)?; + let payer_account = next_account_info(accounts_iter)?; + let _system_program = next_account_info(accounts_iter)?; + + assert!(payer_account.is_signer, "Payer must be a signer"); + assert_eq!(feed_account.owner, program_id, "Invalid PDA Account Owner"); + assert_eq!( + globalstate_account.owner, program_id, + "Invalid GlobalState Account Owner" + ); + assert!(feed_account.is_writable, "PDA Account is not writable"); + + let mut feed = Feed::try_from(feed_account)?; + let globalstate = GlobalState::try_from(globalstate_account)?; + require_feed_writer( + program_id, + accounts_iter, + payer_account.key, + &globalstate, + &feed, + )?; + + // Only a publishing feed can stop publishing. Pending has not started and Retired is terminal, + // so neither is a halt this instruction can honor. + if feed.status != FeedStatus::Active { + msg!( + "Feed {} is {}, so there is nothing to halt", + feed_account.key, + feed.status + ); + return Err(DoubleZeroError::FeedNotHaltable.into()); + } + + feed.status = FeedStatus::Halted; + try_acc_write(&feed, feed_account, payer_account, accounts)?; + + msg!("Halted feed: {}", feed_account.key); + + Ok(()) +} diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/feed/mod.rs b/smartcontract/programs/doublezero-serviceability/src/processors/feed/mod.rs index be42316cb5..f94f577a1b 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/feed/mod.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/feed/mod.rs @@ -1,12 +1,17 @@ pub mod create; pub mod delete; +pub mod halt; +pub mod resume; pub mod update; use crate::{ + authorize::authorize, error::DoubleZeroError, state::{ accesspass::AccessPass, feed::{Feed, FeedStatus}, + globalstate::GlobalState, + permission::permission_flags, }, }; use solana_program::{ @@ -120,3 +125,35 @@ pub fn require_feed_admits(feed_key: &Pubkey, feed: &Feed) -> Result<(), DoubleZ } Ok(()) } + +/// Whether `payer` may change this feed's lifecycle. +/// +/// Two ways in, for different reasons. The feed's own builder, because RFC-28 makes halt the +/// builder's lever and a builder that cannot halt its own feed cannot rotate its upstream source. +/// A `FEED_AUTHORITY` or `FOUNDATION` key, because a feed whose builder has gone quiet must still +/// be stoppable, and every other feed instruction already authorizes that way. +/// +/// A catalog feed has no builder, so only the second way applies to it. The default pubkey is not +/// a signer anyone can produce, but the check is explicit rather than relying on that. +pub fn require_feed_writer<'a, 'b: 'a, I>( + program_id: &Pubkey, + accounts_iter: &mut I, + payer: &Pubkey, + globalstate: &GlobalState, + feed: &Feed, +) -> ProgramResult +where + I: Iterator>, +{ + if feed.builder != Pubkey::default() && &feed.builder == payer { + return Ok(()); + } + + authorize( + program_id, + accounts_iter, + payer, + globalstate, + permission_flags::FEED_AUTHORITY | permission_flags::FOUNDATION, + ) +} diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/feed/resume.rs b/smartcontract/programs/doublezero-serviceability/src/processors/feed/resume.rs new file mode 100644 index 0000000000..85ec8c3b36 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/processors/feed/resume.rs @@ -0,0 +1,72 @@ +use crate::{ + error::DoubleZeroError, + processors::feed::require_feed_writer, + serializer::try_acc_write, + state::{ + feed::{Feed, FeedStatus}, + globalstate::GlobalState, + }, +}; +use borsh::BorshSerialize; +use borsh_incremental::BorshDeserializeIncremental; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + msg, + pubkey::Pubkey, +}; + +#[derive(BorshSerialize, BorshDeserializeIncremental, PartialEq, Debug, Clone, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct FeedResumeArgs {} + +/// Put a halted feed back to publishing. +/// +/// Only from `Halted`. Resuming a `Pending` feed would skip the conformance verdict it is waiting +/// on, and nothing leaves `Retired`. +pub fn process_resume_feed( + program_id: &Pubkey, + accounts: &[AccountInfo], + _value: &FeedResumeArgs, +) -> ProgramResult { + let accounts_iter = &mut accounts.iter(); + + let feed_account = next_account_info(accounts_iter)?; + let globalstate_account = next_account_info(accounts_iter)?; + let payer_account = next_account_info(accounts_iter)?; + let _system_program = next_account_info(accounts_iter)?; + + assert!(payer_account.is_signer, "Payer must be a signer"); + assert_eq!(feed_account.owner, program_id, "Invalid PDA Account Owner"); + assert_eq!( + globalstate_account.owner, program_id, + "Invalid GlobalState Account Owner" + ); + assert!(feed_account.is_writable, "PDA Account is not writable"); + + let mut feed = Feed::try_from(feed_account)?; + let globalstate = GlobalState::try_from(globalstate_account)?; + require_feed_writer( + program_id, + accounts_iter, + payer_account.key, + &globalstate, + &feed, + )?; + + if feed.status != FeedStatus::Halted { + msg!( + "Feed {} is {}, so there is nothing to resume", + feed_account.key, + feed.status + ); + return Err(DoubleZeroError::FeedNotResumable.into()); + } + + feed.status = FeedStatus::Active; + try_acc_write(&feed, feed_account, payer_account, accounts)?; + + msg!("Resumed feed: {}", feed_account.key); + + Ok(()) +} diff --git a/smartcontract/programs/doublezero-serviceability/tests/feed_lifecycle_test.rs b/smartcontract/programs/doublezero-serviceability/tests/feed_lifecycle_test.rs new file mode 100644 index 0000000000..8775f78319 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/tests/feed_lifecycle_test.rs @@ -0,0 +1,320 @@ +//! Halt and resume, and every transition they refuse (RFC-28 D1). +//! +//! The status field and the gate that reads it landed in A5. What was missing was any way to move +//! the status, so a staked feed sat in `Pending` for life and no feed could stop publishing. + +use doublezero_serviceability::{ + error::DoubleZeroError, + instructions::DoubleZeroInstruction, + pda::{get_feed_pda, get_globalstate_pda, get_stake_mirror_pda}, + processors::{ + feed::{create::FeedCreateArgs, halt::FeedHaltArgs, resume::FeedResumeArgs}, + globalstate::setfeatureflags::SetFeatureFlagsArgs, + }, + state::{ + accounttype::AccountType, + feature_flags::FeatureFlag, + feed::{Feed, FeedStatus}, + stake_mirror::{StakeMirror, StakeTier}, + }, +}; +use solana_program_test::*; +use solana_sdk::{ + instruction::AccountMeta, + pubkey::Pubkey, + signature::{Keypair, Signer}, +}; + +mod test_helpers; +use test_helpers::*; + +const ONE_GBPS: u64 = 1_000_000_000; + +/// The accounts every lifecycle instruction takes. The harness appends payer and system program. +fn feed_accounts(feed: Pubkey, globalstate: Pubkey) -> Vec { + vec![ + AccountMeta::new(feed, false), + AccountMeta::new(globalstate, false), + ] +} + +async fn feed_status(banks_client: &mut BanksClient, feed: Pubkey) -> FeedStatus { + get_account_data(banks_client, feed) + .await + .expect("the feed should exist") + .get_feed() + .expect("it should be a feed") + .status +} + +/// A catalog feed: no builder, so it is `Active` the moment it is created. That is the only way to +/// get an `Active` feed today, because `Pending` to `Active` is G1's job and does not exist yet. +async fn catalog_feed(code: &str) -> (BanksClient, Pubkey, Keypair, Pubkey, Pubkey) { + let program_id = Pubkey::new_unique(); + let (mut banks_client, payer, recent_blockhash) = + init_test_with_accounts(program_id, &[]).await; + init_globalstate(&mut banks_client, program_id, &payer, recent_blockhash).await; + let (globalstate, _) = get_globalstate_pda(&program_id); + + let exchange = Pubkey::new_unique(); + let (feed, _) = get_feed_pda(&program_id, code, &exchange); + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateFeed(FeedCreateArgs { + code: code.to_string(), + name: "Catalog".to_string(), + exchange, + groups: vec![Pubkey::new_unique()], + ..Default::default() + }), + feed_accounts(feed, globalstate), + &payer, + ) + .await; + + assert_eq!( + feed_status(&mut banks_client, feed).await, + FeedStatus::Active, + "a catalog feed is sellable on creation" + ); + + (banks_client, program_id, payer, globalstate, feed) +} + +/// A staked feed, whose builder is a key the test can sign with. It is created `Pending`. +async fn staked_feed_owned_by( + builder: &Keypair, + code: &str, +) -> (BanksClient, Pubkey, Keypair, Pubkey, Pubkey) { + let program_id = Pubkey::new_unique(); + let stake_ref = Pubkey::new_unique(); + let (mirror, bump) = get_stake_mirror_pda(&program_id, &stake_ref); + + let mirror_data = borsh::to_vec(&StakeMirror { + account_type: AccountType::StakeMirror, + owner: Pubkey::new_unique(), + bump_seed: bump, + stake_ref, + builder: builder.pubkey(), + tier: StakeTier::UpTo1Gbps, + committed_rate_bits_per_sec: ONE_GBPS, + source_slot: 1, + relayer: Pubkey::new_unique(), + feed_key: Pubkey::default(), + }) + .unwrap(); + + let (mut banks_client, payer, recent_blockhash) = + init_test_with_accounts(program_id, &[(mirror, mirror_data)]).await; + init_globalstate(&mut banks_client, program_id, &payer, recent_blockhash).await; + let (globalstate, _) = get_globalstate_pda(&program_id); + + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::SetFeatureFlags(SetFeatureFlagsArgs { + feature_flags: FeatureFlag::AllowStakedFeeds.to_mask(), + }), + vec![AccountMeta::new(get_globalstate_pda(&program_id).0, false)], + &payer, + ) + .await; + + let exchange = Pubkey::new_unique(); + let (feed, _) = get_feed_pda(&program_id, code, &exchange); + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + execute_transaction_with_extra_accounts( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateFeed(FeedCreateArgs { + code: code.to_string(), + name: "Staked".to_string(), + exchange, + groups: vec![Pubkey::new_unique()], + builder: builder.pubkey(), + stake_ref, + spec_id: "top-of-book@v1.0.0".to_string(), + sla_hash: [9u8; 32], + committed_rate_bits_per_sec: ONE_GBPS, + }), + feed_accounts(feed, globalstate), + &payer, + &[AccountMeta::new(mirror, false)], + ) + .await; + + assert_eq!( + feed_status(&mut banks_client, feed).await, + FeedStatus::Pending, + "a staked feed waits on a conformance verdict" + ); + + (banks_client, program_id, payer, globalstate, feed) +} + +/// Halt stops publication and resume starts it again, both reversible and neither terminal. +#[tokio::test] +async fn test_a_feed_halts_and_resumes() { + let (mut banks_client, program_id, payer, globalstate, feed) = catalog_feed("cycle").await; + + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::HaltFeed(FeedHaltArgs {}), + feed_accounts(feed, globalstate), + &payer, + ) + .await; + assert_eq!( + feed_status(&mut banks_client, feed).await, + FeedStatus::Halted + ); + + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ResumeFeed(FeedResumeArgs {}), + feed_accounts(feed, globalstate), + &payer, + ) + .await; + assert_eq!( + feed_status(&mut banks_client, feed).await, + FeedStatus::Active, + "halt is reversible, which is what makes it not retirement" + ); + + // Everything else about the feed is untouched. Halt is a status change, not an edit. + let feed_account: Feed = get_account_data(&mut banks_client, feed) + .await + .unwrap() + .get_feed() + .unwrap(); + assert_eq!(feed_account.name, "Catalog"); + assert_eq!(feed_account.groups.len(), 1); +} + +/// Halting twice is refused rather than quietly accepted, so a caller cannot mistake a no-op for +/// having stopped something. +#[tokio::test] +async fn test_halting_a_halted_feed_is_refused() { + let (mut banks_client, program_id, payer, globalstate, feed) = catalog_feed("twice").await; + + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::HaltFeed(FeedHaltArgs {}), + feed_accounts(feed, globalstate), + &payer, + ) + .await; + + let result = try_execute_and_get_error( + &mut banks_client, + program_id, + DoubleZeroInstruction::HaltFeed(FeedHaltArgs {}), + feed_accounts(feed, globalstate), + &payer, + &[], + ) + .await; + assert_custom_at_ix0(&result, custom_code(DoubleZeroError::FeedNotHaltable)); +} + +/// Resuming a feed that never halted is refused for the same reason. +#[tokio::test] +async fn test_resuming_an_active_feed_is_refused() { + let (mut banks_client, program_id, payer, globalstate, feed) = catalog_feed("running").await; + + let result = try_execute_and_get_error( + &mut banks_client, + program_id, + DoubleZeroInstruction::ResumeFeed(FeedResumeArgs {}), + feed_accounts(feed, globalstate), + &payer, + &[], + ) + .await; + assert_custom_at_ix0(&result, custom_code(DoubleZeroError::FeedNotResumable)); +} + +/// A `Pending` feed is not publishing, so there is nothing to halt and nothing to resume. +/// +/// This is also the guard on G1's territory: nothing here moves a feed out of `Pending`, because +/// that step re-reads the stake mirror and belongs with the code that does. +#[tokio::test] +async fn test_a_pending_feed_neither_halts_nor_resumes() { + let builder = test_payer(); + let (mut banks_client, program_id, payer, globalstate, feed) = + staked_feed_owned_by(&builder, "waiting").await; + + let result = try_execute_and_get_error( + &mut banks_client, + program_id, + DoubleZeroInstruction::HaltFeed(FeedHaltArgs {}), + feed_accounts(feed, globalstate), + &payer, + &[], + ) + .await; + assert_custom_at_ix0(&result, custom_code(DoubleZeroError::FeedNotHaltable)); + + let result = try_execute_and_get_error( + &mut banks_client, + program_id, + DoubleZeroInstruction::ResumeFeed(FeedResumeArgs {}), + feed_accounts(feed, globalstate), + &payer, + &[], + ) + .await; + assert_custom_at_ix0(&result, custom_code(DoubleZeroError::FeedNotResumable)); +} + +/// The builder is authorized on its own feed, and a stranger is not. +/// +/// Both calls fail, because a `Pending` feed cannot be halted by anyone and G1 does not exist yet +/// to make one `Active`. The errors say which check stopped each one: the builder reaches the +/// status check, and the stranger does not get past authorization. +#[tokio::test] +async fn test_the_builder_is_authorized_on_its_own_feed() { + let builder = test_payer(); + let (mut banks_client, program_id, _payer, globalstate, feed) = + staked_feed_owned_by(&builder, "mine").await; + + let result = try_execute_and_get_error( + &mut banks_client, + program_id, + DoubleZeroInstruction::HaltFeed(FeedHaltArgs {}), + feed_accounts(feed, globalstate), + &builder, + &[], + ) + .await; + assert_custom_at_ix0(&result, custom_code(DoubleZeroError::FeedNotHaltable)); + + let stranger = Keypair::new(); + transfer(&mut banks_client, &builder, &stranger.pubkey(), 10_000_000).await; + let result = try_execute_and_get_error( + &mut banks_client, + program_id, + DoubleZeroInstruction::HaltFeed(FeedHaltArgs {}), + feed_accounts(feed, globalstate), + &stranger, + &[], + ) + .await; + assert_custom_at_ix0(&result, custom_code(DoubleZeroError::NotAllowed)); +} From d0a6b09c9f85fa28408a6d43b176c38c85e33d8c Mon Sep 17 00:00:00 2001 From: Ben Marx Date: Wed, 9 Sep 2026 19:44:49 -0700 Subject: [PATCH 2/4] serviceability: an operator's halt is not the builder's to lift Three review comments, and the first one is a hole. Resume took the same signers as halt, so a builder undid an operator's halt the moment it landed. `Retired` is unreachable until D2 and `DeleteFeed` refuses a staked feed, so that halt is the only lever an operator has, and a builder that can reverse it leaves no lever at all. `Feed` now records `halted_by`, and a halt by anyone other than the builder takes an operator to lift. A builder's own halt is still the builder's to lift, which is the source rotation the whole instruction exists for. Resume also re-proves the stake. A mirror can be corrected downward while a feed sits halted, so resuming on the check made at creation would let a feed publish at a rate its stake no longer backs. A staked feed that omits its mirror is refused rather than read as having no stake to check. The builders had no test. They now pin the tag byte and the account metas, which is what catches a wrong variant: `unpack` matches the leading byte by hand with a catch-all, so the compiler never sees it. --- CHANGELOG.md | 2 +- .../src/feed.rs | 10 + .../src/processors/feed/create.rs | 1 + .../src/processors/feed/halt.rs | 2 + .../src/processors/feed/mod.rs | 48 +++- .../src/processors/feed/resume.rs | 57 ++++- .../src/state/feed.rs | 16 ++ .../tests/feed_lifecycle_test.rs | 213 ++++++++++++++++++ 8 files changed, 339 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cb5880377..340f559e8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ All notable changes to this project will be documented in this file. - CI - The Agave toolchain install retries, and a failed one now fails the job. Eight workflow steps across five workflows ran `sh -c "$(curl -sSfL .../install)"` once with no retry, so a transient reset from `release.anza.xyz` killed a job before it ran anything. That form also swallowed a failed fetch: the command substitution comes back empty, `sh -c ""` exits 0, and the step passed having installed nothing. The eight are now one composite action at `.github/actions/solana-toolchain` that fetches and runs as separate steps, checks `solana --version` actually runs, clears partial state between attempts, bounds every wait so a stalled handshake or hung transfer reaches the backoff instead of sitting until the job times out, and backs off. Each caller keeps the version it used before; `solana.yml` and `offchain.local-validator.yml` are on v3.0.12 and the other three on v3.0.4, which is drift worth settling separately. - Serviceability - - `HaltFeed` (variant 120) and `ResumeFeed` (variant 121), so a feed's status can change. `FeedStatus` and the gate that reads it landed earlier, but nothing could move the status, which left a staked feed in `Pending` for life and gave no feed a way to stop publishing. Halt goes only from `Active` and resume only from `Halted`; every other transition is refused by name, `FeedNotHaltable` (124) or `FeedNotResumable` (125). `Pending` to `Active` is deliberately absent, because that step re-reads the stake mirror and belongs with the code that does. The feed's own `builder` may sign either instruction, which no other feed instruction allows: RFC-28 makes halt the builder's lever and doubles it as upstream-source rotation, so a builder that cannot halt its own feed cannot rotate either. A `FEED_AUTHORITY` or `FOUNDATION` key may sign as well, because a feed whose builder has gone quiet must still be stoppable. + - `HaltFeed` (variant 120) and `ResumeFeed` (variant 121), so a feed's status can change. `FeedStatus` and the gate that reads it landed earlier, but nothing could move the status, which left a staked feed in `Pending` for life and gave no feed a way to stop publishing. Halt goes only from `Active` and resume only from `Halted`; every other transition is refused by name, `FeedNotHaltable` (124) or `FeedNotResumable` (125). `Pending` to `Active` is deliberately absent, because that step re-reads the stake mirror and belongs with the code that does. The feed's own `builder` may sign either instruction, which no other feed instruction allows: RFC-28 makes halt the builder's lever and doubles it as upstream-source rotation, so a builder that cannot halt its own feed cannot rotate either. A `FEED_AUTHORITY` or `FOUNDATION` key may sign as well, because a feed whose builder has gone quiet must still be stoppable. `Feed` gains `halted_by`, so an operator's halt can only be lifted by an operator: a builder that could undo it leaves an operator no lever at all, with `Retired` unreachable and `DeleteFeed` refusing a staked feed. Resuming a staked feed re-proves that its stake still covers its rate, because a mirror can be corrected downward while the feed sits halted. - `UpdateMulticastGroupRoles` authorizes only roles a user gains. An existing feed subscription no longer needs a direct subscriber allowlist entry when the user adds publishing. (malbeclabs/infra#2596) - `write_stake_mirror` in the instruction crate and `WriteStakeMirrorCommand` in the Rust SDK, so the relayer has a caller-side surface for the instruction A6 added. The command departs from its neighbours in one way: every other one uses `append_payer_permission_account`, which attaches the caller's `Permission` account only when it already exists, because a legacy `GlobalState` key might authorize instead. No legacy key satisfies `STAKE_ORACLE`, so a missing `Permission` account is always fatal here, and the command says so locally rather than sending a transaction that can only come back `NotAllowed`. It checks what `authorize` checks, not just ownership: an account that does not decode, a suspended `Permission`, and one lacking the flag are each refused with the reason named, which matters because suspending a `Permission` is what revoking the relayer's key looks like. - New `WriteStakeMirror` instruction (variant 119) and a `STAKE_ORACLE` permission, which is what a relayer will call to copy a builder's Solana stake onto the DZ ledger. Nothing could write a `StakeMirror` before this. The instruction refuses a write whose `source_slot` is not newer than the stored one: polling makes a repeated write harmless but says nothing about ordering, and a retry carrying an older read could otherwise walk the mirror backwards, so the program enforces it rather than trusting the caller. It also refuses to reassign a mirror to a different builder, since the builder is not a PDA seed, and it carries `feed_key` forward rather than taking it from the caller, because `CreateFeed` writes that to spend the stake and zeroing it would let one bond back two feeds. No legacy GlobalState key maps to `STAKE_ORACLE`, so a holder needs a real `Permission` account even while `require-permission-accounts` is clear. Gated on `allow-staked-feeds` with the rest of RFC-28. `STAKE_ORACLE` is grantable through `doublezero permission set --add stake-oracle`, named by `permission audit` and `bitmask_to_names`, listed in `AUTHORIZE_GATED_FLAGS` and in the Go SDK's flag constants. It is the first flag no legacy `GlobalState` key can satisfy, which the audit's own test now asserts through a `PERMISSION_ONLY_FLAGS` list rather than treating as a gap in the enumeration. diff --git a/crates/doublezero-serviceability-instruction/src/feed.rs b/crates/doublezero-serviceability-instruction/src/feed.rs index 54f11a290a..3de374bf7a 100644 --- a/crates/doublezero-serviceability-instruction/src/feed.rs +++ b/crates/doublezero-serviceability-instruction/src/feed.rs @@ -189,6 +189,16 @@ mod tests { let delete = delete_feed(&pid, &payer, &feed, FeedDeleteArgs {}); assert_eq!(delete.data[0], 114); assert_eq!(delete.accounts, expected); + + // The lifecycle verbs take the same accounts. `unpack` matches the leading byte by hand + // with a catch-all, so a wrong tag here reaches the program as `InvalidInstructionData` + // rather than as a compile error. + let halt = halt_feed(&pid, &payer, &feed); + assert_eq!(halt.data[0], 120); + assert_eq!(halt.accounts, expected); + let resume = resume_feed(&pid, &payer, &feed); + assert_eq!(resume.data[0], 121); + assert_eq!(resume.accounts, expected); } /// Tripwire for the module-doc note: `FEED_AUTHORITY` is currently absent from diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/feed/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/feed/create.rs index 96a3eeb0f6..0d3e173dbc 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/feed/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/feed/create.rs @@ -153,6 +153,7 @@ pub fn process_create_feed( } else { FeedStatus::Pending }, + halted_by: Pubkey::default(), }; try_acc_create( diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/feed/halt.rs b/smartcontract/programs/doublezero-serviceability/src/processors/feed/halt.rs index e65dbe93e0..d94cc1d655 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/feed/halt.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/feed/halt.rs @@ -67,6 +67,8 @@ pub fn process_halt_feed( } feed.status = FeedStatus::Halted; + // Recorded so resume can tell an operator's halt from the builder's own. + feed.halted_by = *payer_account.key; try_acc_write(&feed, feed_account, payer_account, accounts)?; msg!("Halted feed: {}", feed_account.key); diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/feed/mod.rs b/smartcontract/programs/doublezero-serviceability/src/processors/feed/mod.rs index f94f577a1b..799895eb06 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/feed/mod.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/feed/mod.rs @@ -6,12 +6,13 @@ pub mod update; use crate::{ authorize::authorize, - error::DoubleZeroError, + error::{DoubleZeroError, Validate}, state::{ accesspass::AccessPass, feed::{Feed, FeedStatus}, globalstate::GlobalState, permission::permission_flags, + stake_mirror::StakeMirror, }, }; use solana_program::{ @@ -157,3 +158,48 @@ where permission_flags::FEED_AUTHORITY | permission_flags::FOUNDATION, ) } + +/// Whether the stake behind `feed` still covers the rate it publishes at. +/// +/// Not the same question `CreateFeed` asks. Creation claims an unspent stake, so it requires +/// `feed_key` to be empty; here the feed already holds the claim, so the mirror must name this +/// feed and no other. What both check is the tier, because a mirror can be corrected downward +/// while a feed sits halted. +pub fn require_stake_still_covers( + program_id: &Pubkey, + mirror_account: &AccountInfo, + feed_key: &Pubkey, + feed: &Feed, +) -> Result<(), DoubleZeroError> { + if mirror_account.data_is_empty() || mirror_account.owner != program_id { + msg!("No stake mirror written for stake {}", feed.stake_ref); + return Err(DoubleZeroError::StakeMirrorMissing); + } + + let mirror = + StakeMirror::try_from(mirror_account).map_err(|_| DoubleZeroError::InvalidAccountType)?; + mirror.validate()?; + + if mirror.stake_ref != feed.stake_ref || mirror.builder != feed.builder { + msg!("Stake mirror names a different stake or builder"); + return Err(DoubleZeroError::InvalidArgument); + } + // The claim has to point back at this feed. A mirror claimed by another feed is not this + // feed's cover, whatever its tier says. + if &mirror.feed_key != feed_key { + msg!("Stake mirror is claimed by feed {}", mirror.feed_key); + return Err(DoubleZeroError::InvalidArgument); + } + + if !mirror.tier.covers(feed.committed_rate_bits_per_sec) { + msg!( + "Tier {} covers up to {} bits/sec, feed commits to {}", + mirror.tier, + mirror.tier.max_rate_bits_per_sec(), + feed.committed_rate_bits_per_sec + ); + return Err(DoubleZeroError::StakeDoesNotCoverRate); + } + + Ok(()) +} diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/feed/resume.rs b/smartcontract/programs/doublezero-serviceability/src/processors/feed/resume.rs index 85ec8c3b36..ea30b37b58 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/feed/resume.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/feed/resume.rs @@ -1,10 +1,13 @@ use crate::{ + authorize::authorize, error::DoubleZeroError, - processors::feed::require_feed_writer, + pda::get_stake_mirror_pda, + processors::feed::{require_feed_writer, require_stake_still_covers}, serializer::try_acc_write, state::{ feed::{Feed, FeedStatus}, globalstate::GlobalState, + permission::permission_flags, }, }; use borsh::BorshSerialize; @@ -46,13 +49,37 @@ pub fn process_resume_feed( let mut feed = Feed::try_from(feed_account)?; let globalstate = GlobalState::try_from(globalstate_account)?; - require_feed_writer( - program_id, - accounts_iter, - payer_account.key, - &globalstate, - &feed, - )?; + + // The stake mirror rides after the fixed accounts, found by its address rather than its + // position, so a catalog feed's caller is not forced to send one. + let tail: Vec<&AccountInfo> = accounts_iter.collect(); + let mirror_key = (feed.builder != Pubkey::default()) + .then(|| get_stake_mirror_pda(program_id, &feed.stake_ref).0); + let mirror_account = mirror_key.and_then(|k| tail.iter().copied().find(|a| a.key == &k)); + let mut authorize_iter = tail.iter().copied().filter(|a| Some(*a.key) != mirror_key); + + // An operator's halt is not the builder's to lift. Letting the builder resume it would undo + // the halt the moment it landed, and with `Retired` unreachable and `DeleteFeed` refusing a + // staked feed, nothing else stops one. + let halted_by_operator = feed.halted_by != Pubkey::default() && feed.halted_by != feed.builder; + if halted_by_operator { + msg!("Feed {} was halted by {}", feed_account.key, feed.halted_by); + authorize( + program_id, + &mut authorize_iter, + payer_account.key, + &globalstate, + permission_flags::FEED_AUTHORITY | permission_flags::FOUNDATION, + )?; + } else { + require_feed_writer( + program_id, + &mut authorize_iter, + payer_account.key, + &globalstate, + &feed, + )?; + } if feed.status != FeedStatus::Halted { msg!( @@ -63,7 +90,21 @@ pub fn process_resume_feed( return Err(DoubleZeroError::FeedNotResumable.into()); } + // A halted feed's stake can be corrected downward while it sits, so publication resumes only + // against cover that still holds. Checking here rather than trusting the check made when the + // feed was created is the difference between a rule and a memory of one. + if let Some(mirror_account) = mirror_account { + require_stake_still_covers(program_id, mirror_account, feed_account.key, &feed)?; + } else if feed.builder != Pubkey::default() { + msg!( + "Staked feed {} needs its stake mirror to resume", + feed_account.key + ); + return Err(DoubleZeroError::StakeMirrorMissing.into()); + } + feed.status = FeedStatus::Active; + feed.halted_by = Pubkey::default(); try_acc_write(&feed, feed_account, payer_account, accounts)?; msg!("Resumed feed: {}", feed_account.key); diff --git a/smartcontract/programs/doublezero-serviceability/src/state/feed.rs b/smartcontract/programs/doublezero-serviceability/src/state/feed.rs index 0e92c1f4cc..0b1a9ca9f2 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/feed.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/feed.rs @@ -98,6 +98,19 @@ pub struct Feed { /// Not basis points: `bps` means basis points elsewhere in DoubleZero. pub committed_rate_bits_per_sec: u64, // 8 pub status: FeedStatus, // 1 + #[cfg_attr( + feature = "serde", + serde( + serialize_with = "doublezero_program_common::serializer::serialize_pubkey_as_string", + deserialize_with = "doublezero_program_common::serializer::deserialize_pubkey_from_string" + ) + )] + /// Who halted this feed, zero when it is not halted. + /// + /// A halt by an operator is not the builder's to lift. Without this the builder resumes the + /// moment an operator halts, and with `Retired` unreachable and `DeleteFeed` refusing a staked + /// feed, nothing else stops one. + pub halted_by: Pubkey, // 32 } impl Feed { @@ -153,6 +166,9 @@ impl TryFrom<&[u8]> for Feed { // Not `Pending`: a feed account written before RFC-28 has no status byte, and reading // one as Pending would pull every live catalog feed out of service. status: BorshDeserialize::deserialize(&mut data).unwrap_or(FeedStatus::Active), + // Zero on a feed written before this field existed, which reads as "not halted by + // anyone" and is right: such a feed cannot have been halted at all. + halted_by: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), }; if out.account_type != AccountType::Feed { diff --git a/smartcontract/programs/doublezero-serviceability/tests/feed_lifecycle_test.rs b/smartcontract/programs/doublezero-serviceability/tests/feed_lifecycle_test.rs index 8775f78319..e0ad658602 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/feed_lifecycle_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/feed_lifecycle_test.rs @@ -318,3 +318,216 @@ async fn test_the_builder_is_authorized_on_its_own_feed() { .await; assert_custom_at_ix0(&result, custom_code(DoubleZeroError::NotAllowed)); } + +/// A halted staked feed, seeded at genesis, with the mirror that covers it. +/// +/// Written rather than driven, because a staked feed cannot reach `Halted` through real +/// instructions yet: it is created `Pending`, and `Pending` to `Active` is G1. The alternative is +/// to leave the rule below untested until G1 lands, which is how the hole this guards against +/// would ship. +fn halted_feed( + program_id: Pubkey, + code: &str, + exchange: Pubkey, + builder: Pubkey, + halted_by: Pubkey, + stake_ref: Pubkey, +) -> (Pubkey, Vec, Pubkey, Vec) { + let (feed_key, bump) = get_feed_pda(&program_id, code, &exchange); + let feed = Feed { + account_type: AccountType::Feed, + owner: Pubkey::new_unique(), + bump_seed: bump, + code: code.to_string(), + name: "Halted".to_string(), + exchange, + groups: vec![Pubkey::new_unique()], + builder, + stake_ref, + spec_id: "top-of-book@v1.0.0".to_string(), + sla_hash: [9u8; 32], + committed_rate_bits_per_sec: ONE_GBPS, + status: FeedStatus::Halted, + halted_by, + }; + + let (mirror_key, mirror_bump) = get_stake_mirror_pda(&program_id, &stake_ref); + let mirror = StakeMirror { + account_type: AccountType::StakeMirror, + owner: Pubkey::new_unique(), + bump_seed: mirror_bump, + stake_ref, + builder, + tier: StakeTier::UpTo1Gbps, + committed_rate_bits_per_sec: ONE_GBPS, + source_slot: 1, + relayer: Pubkey::new_unique(), + // The feed already holds the claim; this is not a creation. + feed_key, + }; + + ( + feed_key, + borsh::to_vec(&feed).unwrap(), + mirror_key, + borsh::to_vec(&mirror).unwrap(), + ) +} + +/// Bring up a cluster holding one halted staked feed and its mirror. +async fn cluster_with_halted_feed( + code: &str, + builder: Pubkey, + halted_by: Pubkey, +) -> (BanksClient, Pubkey, Keypair, Pubkey, Pubkey, Pubkey) { + let program_id = Pubkey::new_unique(); + let (feed_key, feed_data, mirror_key, mirror_data) = halted_feed( + program_id, + code, + Pubkey::new_unique(), + builder, + halted_by, + Pubkey::new_unique(), + ); + + let (mut banks_client, payer, recent_blockhash) = init_test_with_accounts( + program_id, + &[(feed_key, feed_data), (mirror_key, mirror_data)], + ) + .await; + init_globalstate(&mut banks_client, program_id, &payer, recent_blockhash).await; + let (globalstate, _) = get_globalstate_pda(&program_id); + + ( + banks_client, + program_id, + payer, + globalstate, + feed_key, + mirror_key, + ) +} + +/// An operator's halt is not the builder's to lift. +/// +/// Without this, an operator's halt buys nothing: the builder resumes the moment it lands. +/// `Retired` is unreachable until D2 and `DeleteFeed` refuses a staked feed, so the halt is the +/// only lever an operator has, and a builder that can undo it leaves no lever at all. +#[tokio::test] +async fn test_a_builder_cannot_lift_an_operator_halt() { + let builder = test_payer(); + let operator = Pubkey::new_unique(); + let (mut banks_client, program_id, _payer, globalstate, feed, mirror) = + cluster_with_halted_feed("seized", builder.pubkey(), operator).await; + + let result = try_execute_and_get_error( + &mut banks_client, + program_id, + DoubleZeroInstruction::ResumeFeed(FeedResumeArgs {}), + feed_accounts(feed, globalstate), + &builder, + &[AccountMeta::new_readonly(mirror, false)], + ) + .await; + assert_custom_at_ix0(&result, custom_code(DoubleZeroError::NotAllowed)); + + assert_eq!( + feed_status(&mut banks_client, feed).await, + FeedStatus::Halted, + "the halt stands" + ); +} + +/// A builder's own halt is the builder's to lift, which is the source rotation RFC-28 asks for. +#[tokio::test] +async fn test_a_builder_lifts_its_own_halt() { + let builder = test_payer(); + let (mut banks_client, program_id, _payer, globalstate, feed, mirror) = + cluster_with_halted_feed("rotate", builder.pubkey(), builder.pubkey()).await; + + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + execute_transaction_with_extra_accounts( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ResumeFeed(FeedResumeArgs {}), + feed_accounts(feed, globalstate), + &builder, + &[AccountMeta::new_readonly(mirror, false)], + ) + .await; + + assert_eq!( + feed_status(&mut banks_client, feed).await, + FeedStatus::Active + ); +} + +/// Resume re-proves the cover. A stake corrected downward while the feed sat halted must not let +/// it publish again at a rate the stake no longer backs. +#[tokio::test] +async fn test_a_feed_cannot_resume_beyond_its_stake() { + let builder = test_payer(); + let program_id = Pubkey::new_unique(); + let stake_ref = Pubkey::new_unique(); + let (feed_key, feed_data, mirror_key, _) = halted_feed( + program_id, + "shrunk", + Pubkey::new_unique(), + builder.pubkey(), + builder.pubkey(), + stake_ref, + ); + + // The same mirror, corrected down to a tier that no longer covers the feed's rate. + let (_, mirror_bump) = get_stake_mirror_pda(&program_id, &stake_ref); + let shrunk = borsh::to_vec(&StakeMirror { + account_type: AccountType::StakeMirror, + owner: Pubkey::new_unique(), + bump_seed: mirror_bump, + stake_ref, + builder: builder.pubkey(), + tier: StakeTier::None, + committed_rate_bits_per_sec: 0, + source_slot: 2, + relayer: Pubkey::new_unique(), + feed_key, + }) + .unwrap(); + + let (mut banks_client, payer, recent_blockhash) = + init_test_with_accounts(program_id, &[(feed_key, feed_data), (mirror_key, shrunk)]).await; + init_globalstate(&mut banks_client, program_id, &payer, recent_blockhash).await; + let (globalstate, _) = get_globalstate_pda(&program_id); + + let result = try_execute_and_get_error( + &mut banks_client, + program_id, + DoubleZeroInstruction::ResumeFeed(FeedResumeArgs {}), + feed_accounts(feed_key, globalstate), + &builder, + &[AccountMeta::new_readonly(mirror_key, false)], + ) + .await; + assert_custom_at_ix0(&result, custom_code(DoubleZeroError::StakeDoesNotCoverRate)); +} + +/// A staked feed cannot resume without its mirror. Omitting the account must not read as "no +/// stake to check". +#[tokio::test] +async fn test_a_staked_feed_cannot_resume_without_its_mirror() { + let builder = test_payer(); + let (mut banks_client, program_id, _payer, globalstate, feed, _mirror) = + cluster_with_halted_feed("nomirror", builder.pubkey(), builder.pubkey()).await; + + let result = try_execute_and_get_error( + &mut banks_client, + program_id, + DoubleZeroInstruction::ResumeFeed(FeedResumeArgs {}), + feed_accounts(feed, globalstate), + &builder, + &[], + ) + .await; + assert_custom_at_ix0(&result, custom_code(DoubleZeroError::StakeMirrorMissing)); +} From 28d9201e0886f9c40e6e53f187a92233c0befb54 Mon Sep 17 00:00:00 2001 From: Ben Marx Date: Thu, 10 Sep 2026 12:46:03 -0700 Subject: [PATCH 3/4] sdk: carry halted_by into the fixtures and the decoders Adding a field to `Feed` broke the fixture generator's exhaustive literal and left the Python and TypeScript decoders reading a feed that ends one field early. The Rust compiler caught the first and could not catch the other two. `halted_by` is appended after `status`, so a feed written before it reads as halted by nobody. That is right rather than merely convenient: such a feed cannot have been halted. `resume_feed` also never appended the stake mirror, so resuming a staked feed through this builder always failed `StakeMirrorMissing`. It now takes the mirror, `None` for a catalog feed, and the test pins where it lands. --- .../src/feed.rs | 39 +++++++++++++++--- .../python/serviceability/state.py | 4 ++ sdk/serviceability/testdata/fixtures/feed.bin | Bin 281 -> 313 bytes .../fixtures/generate-fixtures/src/main.rs | 1 + .../typescript/serviceability/state.ts | 5 +++ 5 files changed, 43 insertions(+), 6 deletions(-) diff --git a/crates/doublezero-serviceability-instruction/src/feed.rs b/crates/doublezero-serviceability-instruction/src/feed.rs index 3de374bf7a..9f7867ea79 100644 --- a/crates/doublezero-serviceability-instruction/src/feed.rs +++ b/crates/doublezero-serviceability-instruction/src/feed.rs @@ -113,12 +113,24 @@ pub fn halt_feed(program_id: &Pubkey, payer: &Pubkey, feed: &Pubkey) -> Instruct ) } -/// `ResumeFeed` (variant 121). Accounts: `[feed, globalstate]`. +/// `ResumeFeed` (variant 121). Accounts: `[feed, globalstate]`, then the stake mirror. /// -/// Puts a halted feed back to publishing. Signed by the same keys `halt_feed` accepts. -pub fn resume_feed(program_id: &Pubkey, payer: &Pubkey, feed: &Pubkey) -> Instruction { +/// Puts a halted feed back to publishing. Signed by the keys `halt_feed` accepts, except that an +/// operator's halt takes an operator to lift. +/// +/// `stake_mirror` is required for a staked feed and must be `None` for a catalog one. Resume +/// re-proves that the stake still covers the feed's rate, because a mirror can be corrected +/// downward while a feed sits halted, so a staked feed without its mirror is refused rather than +/// read as having no stake to check. It rides after the payer and system program, found by its +/// address rather than its position, so a catalog feed's caller is not forced to send one. +pub fn resume_feed( + program_id: &Pubkey, + payer: &Pubkey, + feed: &Pubkey, + stake_mirror: Option<&Pubkey>, +) -> Instruction { let (globalstate, _) = get_globalstate_pda(program_id); - common::build_with_permission( + let mut ix = common::build_with_permission( program_id, DoubleZeroInstruction::ResumeFeed(FeedResumeArgs {}), vec![ @@ -126,7 +138,12 @@ pub fn resume_feed(program_id: &Pubkey, payer: &Pubkey, feed: &Pubkey) -> Instru AccountMeta::new(globalstate, false), ], payer, - ) + ); + if let Some(stake_mirror) = stake_mirror { + ix.accounts + .push(AccountMeta::new_readonly(*stake_mirror, false)); + } + ix } #[cfg(test)] @@ -196,9 +213,19 @@ mod tests { let halt = halt_feed(&pid, &payer, &feed); assert_eq!(halt.data[0], 120); assert_eq!(halt.accounts, expected); - let resume = resume_feed(&pid, &payer, &feed); + let resume = resume_feed(&pid, &payer, &feed, None); assert_eq!(resume.data[0], 121); assert_eq!(resume.accounts, expected); + + // A staked feed's mirror rides after the payer and system program, where the processor + // looks for it. Without it, resume refuses with `StakeMirrorMissing`. + let mirror = Pubkey::new_unique(); + let staked = resume_feed(&pid, &payer, &feed, Some(&mirror)); + assert_eq!(staked.accounts[..expected.len()], expected[..]); + assert_eq!( + staked.accounts.last().unwrap(), + &AccountMeta::new_readonly(mirror, false) + ); } /// Tripwire for the module-doc note: `FEED_AUTHORITY` is currently absent from diff --git a/sdk/serviceability/python/serviceability/state.py b/sdk/serviceability/python/serviceability/state.py index e6e3464020..2770992d2b 100644 --- a/sdk/serviceability/python/serviceability/state.py +++ b/sdk/serviceability/python/serviceability/state.py @@ -1275,6 +1275,9 @@ class Feed: sla_hash: bytes = b"\x00" * 32 committed_rate_bits_per_sec: int = 0 status: int = 0 + # Who halted the feed, default when it is not halted. Appended after status, so a feed + # written before it reads as halted by nobody, which is right: it cannot have been halted. + halted_by: Pubkey = Pubkey.default() pub_key: Pubkey = Pubkey.default() # set from account address after deserialization @classmethod @@ -1301,4 +1304,5 @@ def from_bytes(cls, data: bytes) -> Feed: # Not pending: a feed written before RFC-28 has no status byte, and reading one as pending # would show every live catalog feed as out of service. f.status = r.read_u8() if has_rfc28_tail else FEED_STATUS_ACTIVE + f.halted_by = _read_pubkey(r) return f diff --git a/sdk/serviceability/testdata/fixtures/feed.bin b/sdk/serviceability/testdata/fixtures/feed.bin index d4036b4c0f90b8fc0e290c69aae8771d324e9959..bad95f9a8c4d19c63dd1c5edbf5e42ed8a5a3b39 100644 GIT binary patch delta 16 XcmbQqw3BIrB;&+1k% Date: Thu, 10 Sep 2026 12:57:34 -0700 Subject: [PATCH 4/4] instruction: create_feed carries a staked feed's stake mirror `CreateFeed` reads the tier from the mirror and writes the feed's key onto it to claim the stake, and the builder never sent it, so a staked create through this crate always failed. Its callers passed the account by hand. Derived rather than taken as a parameter. Unlike `resume_feed`, the args already carry the stake the mirror is seeded on, so asking a caller for it would only add a way to get it wrong. A catalog feed sends none, so a caller that sent neither before is unaffected. --- .../src/feed.rs | 70 ++++++++++++++++++- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/crates/doublezero-serviceability-instruction/src/feed.rs b/crates/doublezero-serviceability-instruction/src/feed.rs index 9f7867ea79..7c9bbe59ee 100644 --- a/crates/doublezero-serviceability-instruction/src/feed.rs +++ b/crates/doublezero-serviceability-instruction/src/feed.rs @@ -28,7 +28,7 @@ use crate::common; use doublezero_serviceability::{ instructions::DoubleZeroInstruction, - pda::{get_feed_pda, get_globalstate_pda}, + pda::{get_feed_pda, get_globalstate_pda, get_stake_mirror_pda}, processors::feed::{ create::FeedCreateArgs, delete::FeedDeleteArgs, halt::FeedHaltArgs, resume::FeedResumeArgs, update::FeedUpdateArgs, @@ -45,7 +45,15 @@ use solana_program::{ pub fn create_feed(program_id: &Pubkey, payer: &Pubkey, args: FeedCreateArgs) -> Instruction { let (feed, _) = get_feed_pda(program_id, &args.code, &args.exchange); let (globalstate, _) = get_globalstate_pda(program_id); - common::build_with_permission( + + // A staked feed needs its stake mirror: `CreateFeed` reads the tier from it and writes the + // feed's key onto it to claim the stake. Derived rather than taken as a parameter, because + // unlike `resume_feed` the args already carry the stake it is seeded on, so asking a caller + // for it would only create a way to get it wrong. Writable: creating the feed claims it. + let stake_mirror = (args.builder != Pubkey::default()) + .then(|| get_stake_mirror_pda(program_id, &args.stake_ref).0); + + let mut ix = common::build_with_permission( program_id, DoubleZeroInstruction::CreateFeed(args), vec![ @@ -53,7 +61,11 @@ pub fn create_feed(program_id: &Pubkey, payer: &Pubkey, args: FeedCreateArgs) -> AccountMeta::new(globalstate, false), ], payer, - ) + ); + if let Some(stake_mirror) = stake_mirror { + ix.accounts.push(AccountMeta::new(stake_mirror, false)); + } + ix } /// `UpdateFeed` (variant 113). Accounts: `[feed, globalstate]`. @@ -151,6 +163,58 @@ mod tests { use super::*; use solana_system_interface::program as system_program; + /// A staked feed carries its mirror; a catalog feed must not, or a caller that sends neither + /// stops working. + #[test] + fn test_create_feed_appends_the_stake_mirror_only_when_staked() { + let pid = Pubkey::new_unique(); + let payer = Pubkey::new_unique(); + let exchange = Pubkey::new_unique(); + let builder = Pubkey::new_unique(); + let stake_ref = Pubkey::new_unique(); + + let staked = create_feed( + &pid, + &payer, + FeedCreateArgs { + code: "staked".to_string(), + name: "Staked".to_string(), + exchange, + groups: vec![Pubkey::new_unique()], + builder, + stake_ref, + spec_id: "top-of-book@v1.0.0".to_string(), + sla_hash: [9u8; 32], + committed_rate_bits_per_sec: 1_000_000_000, + }, + ); + let (mirror, _) = get_stake_mirror_pda(&pid, &stake_ref); + assert_eq!( + staked.accounts.last().unwrap(), + &AccountMeta::new(mirror, false), + "the mirror is writable: creating the feed claims the stake" + ); + + let catalog = create_feed( + &pid, + &payer, + FeedCreateArgs { + code: "catalog".to_string(), + name: "Catalog".to_string(), + exchange, + groups: vec![Pubkey::new_unique()], + ..Default::default() + }, + ); + assert!( + !catalog + .accounts + .iter() + .any(|a| a.pubkey == get_stake_mirror_pda(&pid, &Pubkey::default()).0), + "a catalog feed sends no mirror" + ); + } + #[test] fn test_create_feed_derives_pda_from_code_and_exchange() { let pid = Pubkey::new_unique();