diff --git a/CHANGELOG.md b/CHANGELOG.md index f3908111de..340f559e8a 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. `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 1c1fae978b..3de374bf7a 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::*; @@ -151,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/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/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 new file mode 100644 index 0000000000..d94cc1d655 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/processors/feed/halt.rs @@ -0,0 +1,77 @@ +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; + // 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); + + 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..799895eb06 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/feed/mod.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/feed/mod.rs @@ -1,12 +1,18 @@ pub mod create; pub mod delete; +pub mod halt; +pub mod resume; pub mod update; use crate::{ - error::DoubleZeroError, + authorize::authorize, + error::{DoubleZeroError, Validate}, state::{ accesspass::AccessPass, feed::{Feed, FeedStatus}, + globalstate::GlobalState, + permission::permission_flags, + stake_mirror::StakeMirror, }, }; use solana_program::{ @@ -120,3 +126,80 @@ 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, + ) +} + +/// 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 new file mode 100644 index 0000000000..ea30b37b58 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/processors/feed/resume.rs @@ -0,0 +1,113 @@ +use crate::{ + authorize::authorize, + error::DoubleZeroError, + 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; +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)?; + + // 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!( + "Feed {} is {}, so there is nothing to resume", + feed_account.key, + feed.status + ); + 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); + + Ok(()) +} 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 new file mode 100644 index 0000000000..e0ad658602 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/tests/feed_lifecycle_test.rs @@ -0,0 +1,533 @@ +//! 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)); +} + +/// 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)); +}