From 07fc67644778ac7c75ff09af2833ca5c7cce2f6b Mon Sep 17 00:00:00 2001 From: Ben Marx Date: Thu, 10 Sep 2026 14:14:25 -0700 Subject: [PATCH 1/5] serviceability: retire a feed, with the notice its seat holders are owed D1 gave a feed a way to stop and start. This gives it a way to end. `RetireFeed` moves a feed to a new `Retiring` state and starts a thirty-day notice; `FinalizeFeedRetirement` moves it to `Retired` once that elapses. Retiring is terminal from the moment it starts: a retiring feed neither halts nor resumes, which is the whole difference between retirement and halt. A `Pending` feed retires with no wait. The notice exists for seat holders and a feed that was never `Active` admitted none, so the wait is zero rather than absent, which keeps one path through retirement instead of two. It has to be retirable at all because `DeleteFeed` refuses a staked feed, so refusing here as well would leave a feed that never went live with no way out. Finalizing is permissionless, unlike every other feed instruction. The clock decided when retirement started and this can only agree with it, so requiring an authority would add a way for a feed to sit in `Retiring` forever because whoever held the key stopped caring. A seat holder given a date deserves the date rather than someone's attention. `FeedRetired` is a `msg!` line. This program has no event mechanism and nothing consumes the event yet, since releasing the stake waits for the slashing work, so inventing one for an absent consumer would buy nothing. `Retiring` takes discriminant 4 rather than a value between `Halted` and `Retired`: those are written into live accounts and renumbering them would reinterpret every stored feed. --- CHANGELOG.md | 1 + .../python/serviceability/state.py | 5 + sdk/serviceability/testdata/fixtures/feed.bin | Bin 313 -> 321 bytes .../fixtures/generate-fixtures/src/main.rs | 1 + .../typescript/serviceability/state.ts | 7 + .../src/entrypoint.rs | 11 +- .../doublezero-serviceability/src/error.rs | 14 +- .../src/instructions.rs | 19 +- .../src/processors/feed/create.rs | 1 + .../processors/feed/finalize_retirement.rs | 80 ++++++ .../src/processors/feed/mod.rs | 2 + .../src/processors/feed/retire.rs | 96 +++++++ .../src/state/feed.rs | 19 +- .../tests/feed_lifecycle_test.rs | 251 +++++++++++++++++- 14 files changed, 498 insertions(+), 9 deletions(-) create mode 100644 smartcontract/programs/doublezero-serviceability/src/processors/feed/finalize_retirement.rs create mode 100644 smartcontract/programs/doublezero-serviceability/src/processors/feed/retire.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c5e6ad4854..7cc67931c3 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 + - `RetireFeed` (variant 122) and `FinalizeFeedRetirement` (variant 123), with a new `Retiring` status and a `retires_at` timestamp on `Feed`. Retiring starts a thirty-day notice to seat holders and is terminal: a retiring feed neither halts nor resumes, and the only state after it is `Retired`. A `Pending` feed retires with no wait, because the notice exists for seat holders and a feed that never went `Active` admitted none; it still has to be retirable, since `DeleteFeed` refuses a staked feed and refusing here too would leave it with no way out. Finalizing is permissionless: the clock has already decided, so requiring an authority would only let a feed sit in `Retiring` because whoever held the key stopped caring. `FeedRetired` is a `msg!` line rather than a new event mechanism, since this program has none and nothing consumes it yet. New errors `FeedNotRetirable` (126), `FeedNotRetiring` (127) and `RetirementNoticeNotElapsed` (128). - `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) - New user accounts record the selected access-pass address, and deletion rejects another pass while preserving legacy address validation. diff --git a/sdk/serviceability/python/serviceability/state.py b/sdk/serviceability/python/serviceability/state.py index c84d353659..0c9f57c4e6 100644 --- a/sdk/serviceability/python/serviceability/state.py +++ b/sdk/serviceability/python/serviceability/state.py @@ -1254,6 +1254,7 @@ def from_bytes(cls, data: bytes) -> TopologyInfo: FEED_STATUS_ACTIVE = 1 FEED_STATUS_HALTED = 2 FEED_STATUS_RETIRED = 3 +FEED_STATUS_RETIRING = 4 @dataclass @@ -1280,6 +1281,9 @@ class Feed: # 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() + # When the retirement notice elapses, zero when the feed is not retiring. Appended after + # halted_by, so a feed written before it reads as not retiring, which is right. + retires_at: int = 0 pub_key: Pubkey = Pubkey.default() # set from account address after deserialization @classmethod @@ -1307,4 +1311,5 @@ def from_bytes(cls, data: bytes) -> Feed: # 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) + f.retires_at = _read_i64(r) return f diff --git a/sdk/serviceability/testdata/fixtures/feed.bin b/sdk/serviceability/testdata/fixtures/feed.bin index bad95f9a8c4d19c63dd1c5edbf5e42ed8a5a3b39..2e2ab56967cdded6dc54a41ff752355e224231bf 100644 GIT binary patch delta 10 RcmdnVbdYI-CF8^b4geKM19AWW delta 7 OcmX@ew3BIrB_jX}f&ys( diff --git a/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs b/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs index dfbcc85500..1a9d841850 100644 --- a/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs +++ b/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs @@ -1520,6 +1520,7 @@ fn generate_feed(dir: &Path) { committed_rate_bits_per_sec: 1_000_000_000, status: FeedStatus::Pending, halted_by: Pubkey::default(), + retires_at: 0, }; let data = borsh::to_vec(&val).unwrap(); diff --git a/sdk/serviceability/typescript/serviceability/state.ts b/sdk/serviceability/typescript/serviceability/state.ts index 45b3ee2358..6fed72cb3b 100644 --- a/sdk/serviceability/typescript/serviceability/state.ts +++ b/sdk/serviceability/typescript/serviceability/state.ts @@ -1267,6 +1267,9 @@ export interface Feed { // Who halted the feed, the default key 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. haltedBy: PublicKey; + // When the retirement notice elapses, zero when the feed is not retiring. Appended after + // haltedBy, so a feed written before it reads as not retiring, which is right. + retiresAt: bigint; } // Feed lifecycle. Matches FeedStatus in the Rust program. @@ -1274,6 +1277,7 @@ export const FEED_STATUS_PENDING = 0; export const FEED_STATUS_ACTIVE = 1; export const FEED_STATUS_HALTED = 2; export const FEED_STATUS_RETIRED = 3; +export const FEED_STATUS_RETIRING = 4; export function deserializeFeed(data: Uint8Array): Feed { const r = new DefensiveReader(data); @@ -1298,6 +1302,8 @@ export function deserializeFeed(data: Uint8Array): Feed { // would show every live catalog feed as out of service. const status = hasRfc28Tail ? r.readU8() : FEED_STATUS_ACTIVE; const haltedBy = readPubkey(r); + // readU64 is unsigned; reinterpret the sign bit, as the seat timestamps above do. + const retiresAt = BigInt.asIntN(64, r.readU64()); return { accountType, owner, @@ -1313,5 +1319,6 @@ export function deserializeFeed(data: Uint8Array): Feed { committedRateBitsPerSec, status, haltedBy, + retiresAt, }; } diff --git a/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs b/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs index 7e536c0fb4..f7b4b33368 100644 --- a/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs +++ b/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs @@ -38,8 +38,9 @@ use crate::{ suspend::process_suspend_exchange, update::process_update_exchange, }, feed::{ - create::process_create_feed, delete::process_delete_feed, halt::process_halt_feed, - resume::process_resume_feed, update::process_update_feed, + create::process_create_feed, delete::process_delete_feed, + finalize_retirement::process_finalize_feed_retirement, halt::process_halt_feed, + resume::process_resume_feed, retire::process_retire_feed, update::process_update_feed, }, globalconfig::set::process_set_globalconfig, globalstate::{ @@ -423,6 +424,12 @@ pub fn process_instruction( process_write_stake_mirror(program_id, accounts, &value)? } DoubleZeroInstruction::HaltFeed(value) => process_halt_feed(program_id, accounts, &value)?, + DoubleZeroInstruction::RetireFeed(value) => { + process_retire_feed(program_id, accounts, &value)? + } + DoubleZeroInstruction::FinalizeFeedRetirement(value) => { + process_finalize_feed_retirement(program_id, accounts, &value)? + } DoubleZeroInstruction::ResumeFeed(value) => { process_resume_feed(program_id, accounts, &value)? } diff --git a/smartcontract/programs/doublezero-serviceability/src/error.rs b/smartcontract/programs/doublezero-serviceability/src/error.rs index 86a77b7fa7..d28db4a2d8 100644 --- a/smartcontract/programs/doublezero-serviceability/src/error.rs +++ b/smartcontract/programs/doublezero-serviceability/src/error.rs @@ -261,6 +261,12 @@ pub enum DoubleZeroError { FeedNotHaltable, // variant 124 #[error("Only a halted feed can be resumed")] FeedNotResumable, // variant 125 + #[error("This feed is already retiring or retired")] + FeedNotRetirable, // variant 126 + #[error("This feed has no retirement to finish")] + FeedNotRetiring, // variant 127 + #[error("The retirement notice has not elapsed")] + RetirementNoticeNotElapsed, // variant 128 } impl From for ProgramError { @@ -392,6 +398,9 @@ impl From for ProgramError { DoubleZeroError::FeedNotActive => ProgramError::Custom(123), DoubleZeroError::FeedNotHaltable => ProgramError::Custom(124), DoubleZeroError::FeedNotResumable => ProgramError::Custom(125), + DoubleZeroError::FeedNotRetirable => ProgramError::Custom(126), + DoubleZeroError::FeedNotRetiring => ProgramError::Custom(127), + DoubleZeroError::RetirementNoticeNotElapsed => ProgramError::Custom(128), } } } @@ -524,6 +533,9 @@ impl From for DoubleZeroError { 123 => DoubleZeroError::FeedNotActive, 124 => DoubleZeroError::FeedNotHaltable, 125 => DoubleZeroError::FeedNotResumable, + 126 => DoubleZeroError::FeedNotRetirable, + 127 => DoubleZeroError::FeedNotRetiring, + 128 => DoubleZeroError::RetirementNoticeNotElapsed, _ => DoubleZeroError::Custom(e), } } @@ -558,7 +570,7 @@ mod tests { } // EnumIter generates Custom(0) by default, so we explicitly test values - // outside the known variant range (currently 0-125) to ensure the conversion + // outside the known variant range (currently 0-128) 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 93e45baea5..89b7f1d505 100644 --- a/smartcontract/programs/doublezero-serviceability/src/instructions.rs +++ b/smartcontract/programs/doublezero-serviceability/src/instructions.rs @@ -28,8 +28,9 @@ use crate::processors::{ setdevice::ExchangeSetDeviceArgs, suspend::ExchangeSuspendArgs, update::ExchangeUpdateArgs, }, feed::{ - create::FeedCreateArgs, delete::FeedDeleteArgs, halt::FeedHaltArgs, resume::FeedResumeArgs, - update::FeedUpdateArgs, + create::FeedCreateArgs, delete::FeedDeleteArgs, + finalize_retirement::FeedFinalizeRetirementArgs, halt::FeedHaltArgs, + resume::FeedResumeArgs, retire::FeedRetireArgs, update::FeedUpdateArgs, }, globalconfig::set::SetGlobalConfigArgs, globalstate::{ @@ -265,8 +266,10 @@ pub enum DoubleZeroInstruction { WriteStakeMirror(StakeMirrorWriteArgs), // variant 119 - HaltFeed(FeedHaltArgs), // variant 120 - ResumeFeed(FeedResumeArgs), // variant 121 + HaltFeed(FeedHaltArgs), // variant 120 + ResumeFeed(FeedResumeArgs), // variant 121 + RetireFeed(FeedRetireArgs), // variant 122 + FinalizeFeedRetirement(FeedFinalizeRetirementArgs), // variant 123 } impl DoubleZeroInstruction { @@ -422,6 +425,10 @@ impl DoubleZeroInstruction { )), 120 => Ok(Self::HaltFeed(FeedHaltArgs::try_from(rest).unwrap())), 121 => Ok(Self::ResumeFeed(FeedResumeArgs::try_from(rest).unwrap())), + 122 => Ok(Self::RetireFeed(FeedRetireArgs::try_from(rest).unwrap())), + 123 => Ok(Self::FinalizeFeedRetirement( + FeedFinalizeRetirementArgs::try_from(rest).unwrap(), + )), _ => Err(ProgramError::InvalidInstructionData), } @@ -571,6 +578,8 @@ impl DoubleZeroInstruction { Self::WriteStakeMirror(_) => "WriteStakeMirror".to_string(), // variant 119 Self::HaltFeed(_) => "HaltFeed".to_string(), // variant 120 Self::ResumeFeed(_) => "ResumeFeed".to_string(), // variant 121 + Self::RetireFeed(_) => "RetireFeed".to_string(), // variant 122 + Self::FinalizeFeedRetirement(_) => "FinalizeFeedRetirement".to_string(), // variant 123 Self::UpdateFeed(_) => "UpdateFeed".to_string(), // variant 113 Self::DeleteFeed(_) => "DeleteFeed".to_string(), // variant 114 Self::SetAccessPassFeeds(_) => "SetAccessPassFeeds".to_string(), // variant 115 @@ -718,6 +727,8 @@ impl DoubleZeroInstruction { Self::WriteStakeMirror(args) => format!("{args:?}"), // variant 119 Self::HaltFeed(args) => format!("{args:?}"), // variant 120 Self::ResumeFeed(args) => format!("{args:?}"), // variant 121 + Self::RetireFeed(args) => format!("{args:?}"), // variant 122 + Self::FinalizeFeedRetirement(args) => format!("{args:?}"), // variant 123 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 0d3e173dbc..5d83f00e15 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/feed/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/feed/create.rs @@ -154,6 +154,7 @@ pub fn process_create_feed( FeedStatus::Pending }, halted_by: Pubkey::default(), + retires_at: 0, }; try_acc_create( diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/feed/finalize_retirement.rs b/smartcontract/programs/doublezero-serviceability/src/processors/feed/finalize_retirement.rs new file mode 100644 index 0000000000..69f60d95e6 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/processors/feed/finalize_retirement.rs @@ -0,0 +1,80 @@ +use crate::{ + error::DoubleZeroError, + serializer::try_acc_write, + state::feed::{Feed, FeedStatus}, +}; +use borsh::BorshSerialize; +use borsh_incremental::BorshDeserializeIncremental; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + clock::Clock, + entrypoint::ProgramResult, + msg, + pubkey::Pubkey, + sysvar::Sysvar, +}; + +#[derive(BorshSerialize, BorshDeserializeIncremental, PartialEq, Debug, Clone, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct FeedFinalizeRetirementArgs {} + +/// Move a feed from `Retiring` to `Retired` once its notice has elapsed. +/// +/// Permissionless, unlike every other feed instruction. The outcome is fixed the moment retirement +/// starts: the clock decides, and this instruction can only agree with it. Requiring an authority +/// would add a way for a feed to sit in `Retiring` forever because whoever held the key stopped +/// caring, and a seat holder given a date deserves the date rather than someone's attention. +pub fn process_finalize_feed_retirement( + program_id: &Pubkey, + accounts: &[AccountInfo], + _value: &FeedFinalizeRetirementArgs, +) -> ProgramResult { + let accounts_iter = &mut accounts.iter(); + + let feed_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!(feed_account.is_writable, "PDA Account is not writable"); + + let mut feed = Feed::try_from(feed_account)?; + + if feed.status != FeedStatus::Retiring { + msg!( + "Feed {} is {}, so it has no retirement to finish", + feed_account.key, + feed.status + ); + return Err(DoubleZeroError::FeedNotRetiring.into()); + } + + let now = Clock::get()?.unix_timestamp; + if now < feed.retires_at { + msg!( + "Feed {} retires at {}, and it is {}", + feed_account.key, + feed.retires_at, + now + ); + return Err(DoubleZeroError::RetirementNoticeNotElapsed.into()); + } + + feed.status = FeedStatus::Retired; + try_acc_write(&feed, feed_account, payer_account, accounts)?; + + // The `FeedRetired` event, as a log line. This program has no event mechanism: no + // `sol_log_data`, no return data, only `msg!`. Nothing consumes this yet, because releasing + // the stake on retirement waits for the slashing work, so inventing a mechanism for a single + // absent consumer would buy nothing. The shape is fixed so that whoever consumes it can parse + // it without this line changing. + msg!( + "FeedRetired feed={} builder={} stake_ref={}", + feed_account.key, + feed.builder, + feed.stake_ref + ); + + Ok(()) +} diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/feed/mod.rs b/smartcontract/programs/doublezero-serviceability/src/processors/feed/mod.rs index 799895eb06..26e0d48c15 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/feed/mod.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/feed/mod.rs @@ -1,7 +1,9 @@ pub mod create; pub mod delete; +pub mod finalize_retirement; pub mod halt; pub mod resume; +pub mod retire; pub mod update; use crate::{ diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/feed/retire.rs b/smartcontract/programs/doublezero-serviceability/src/processors/feed/retire.rs new file mode 100644 index 0000000000..8a9edbcafe --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/processors/feed/retire.rs @@ -0,0 +1,96 @@ +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}, + clock::Clock, + entrypoint::ProgramResult, + msg, + pubkey::Pubkey, + sysvar::Sysvar, +}; + +/// The notice a feed owes its seat holders before publication stops. +pub const RETIREMENT_NOTICE_SECONDS: i64 = 30 * 24 * 60 * 60; + +#[derive(BorshSerialize, BorshDeserializeIncremental, PartialEq, Debug, Clone, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct FeedRetireArgs {} + +/// Start a feed's retirement and the notice that runs with it. +/// +/// One way in and no way back: `Retiring` is not resumable, and the only state after it is +/// `Retired`. That is what makes retirement terminal where halt is reversible. +pub fn process_retire_feed( + program_id: &Pubkey, + accounts: &[AccountInfo], + _value: &FeedRetireArgs, +) -> 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, + )?; + + // A feed already retiring or retired has nothing left to start. Everything else can retire, + // including `Pending`: `DeleteFeed` refuses a staked feed, so refusing here as well would + // leave a feed that never went live with no way out at all. + if matches!(feed.status, FeedStatus::Retiring | FeedStatus::Retired) { + msg!( + "Feed {} is {}, so its retirement has already started", + feed_account.key, + feed.status + ); + return Err(DoubleZeroError::FeedNotRetirable.into()); + } + + let now = Clock::get()?.unix_timestamp; + + // The notice exists for seat holders, and a feed that was never `Active` has none: it admits + // no subscriber until it publishes. So the wait is zero rather than absent, which keeps one + // path through retirement instead of two. + feed.retires_at = if feed.status == FeedStatus::Pending { + now + } else { + now.checked_add(RETIREMENT_NOTICE_SECONDS) + .ok_or(DoubleZeroError::InvalidArgument)? + }; + feed.status = FeedStatus::Retiring; + + try_acc_write(&feed, feed_account, payer_account, accounts)?; + + msg!( + "Retiring feed: {} notice ends at {}", + feed_account.key, + feed.retires_at + ); + + Ok(()) +} diff --git a/smartcontract/programs/doublezero-serviceability/src/state/feed.rs b/smartcontract/programs/doublezero-serviceability/src/state/feed.rs index 0b1a9ca9f2..0ac70cc621 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/feed.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/feed.rs @@ -23,8 +23,14 @@ pub enum FeedStatus { Active = 1, /// Publication stopped by the builder. Resumable. Halted = 2, - /// Terminal. Set after the thirty-day notice elapses. + /// Terminal. Set after the notice elapses. Retired = 3, + /// Retiring, with the notice to seat holders running. Not resumable: the only way out is + /// `Retired`. + /// + /// Discriminant 4 rather than a value between `Halted` and `Retired`, because those are + /// written into live accounts and renumbering them would reinterpret every stored feed. + Retiring = 4, } impl fmt::Display for FeedStatus { @@ -34,6 +40,7 @@ impl fmt::Display for FeedStatus { FeedStatus::Active => "active", FeedStatus::Halted => "halted", FeedStatus::Retired => "retired", + FeedStatus::Retiring => "retiring", }; write!(f, "{s}") } @@ -111,6 +118,13 @@ pub struct Feed { /// moment an operator halts, and with `Retired` unreachable and `DeleteFeed` refusing a staked /// feed, nothing else stops one. pub halted_by: Pubkey, // 32 + /// When this feed's retirement notice elapses, as a unix timestamp, zero when it is not + /// retiring. + /// + /// Set when retirement starts and never moved, so the date a seat holder was given is the + /// date that arrives. A feed that never sold a seat gets `now`, because the notice exists for + /// seat holders and a feed that was never `Active` has none. + pub retires_at: i64, // 8 } impl Feed { @@ -169,6 +183,9 @@ impl TryFrom<&[u8]> for Feed { // 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(), + // Zero on a feed written before this field, which reads as "not retiring" and is + // right: such a feed cannot have started a notice. + retires_at: 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 e0ad658602..e76537d542 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/feed_lifecycle_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/feed_lifecycle_test.rs @@ -7,8 +7,12 @@ use doublezero_serviceability::{ error::DoubleZeroError, instructions::DoubleZeroInstruction, pda::{get_feed_pda, get_globalstate_pda, get_stake_mirror_pda}, + processors::feed::retire::RETIREMENT_NOTICE_SECONDS, processors::{ - feed::{create::FeedCreateArgs, halt::FeedHaltArgs, resume::FeedResumeArgs}, + feed::{ + create::FeedCreateArgs, finalize_retirement::FeedFinalizeRetirementArgs, + halt::FeedHaltArgs, resume::FeedResumeArgs, retire::FeedRetireArgs, + }, globalstate::setfeatureflags::SetFeatureFlagsArgs, }, state::{ @@ -349,6 +353,7 @@ fn halted_feed( committed_rate_bits_per_sec: ONE_GBPS, status: FeedStatus::Halted, halted_by, + retires_at: 0, }; let (mirror_key, mirror_bump) = get_stake_mirror_pda(&program_id, &stake_ref); @@ -531,3 +536,247 @@ async fn test_a_staked_feed_cannot_resume_without_its_mirror() { .await; assert_custom_at_ix0(&result, custom_code(DoubleZeroError::StakeMirrorMissing)); } + +/// A feed in `Retiring`, seeded with the notice ending at `retires_at`. +/// +/// Seeded rather than driven so the notice can be placed in the past or the future without moving +/// the validator's clock. The comparison against `Clock` is what these tests are about; how the +/// timestamp got there is `retire`'s business and has its own tests above. +fn retiring_feed( + program_id: Pubkey, + code: &str, + exchange: Pubkey, + builder: Pubkey, + retires_at: i64, +) -> (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: "Retiring".to_string(), + exchange, + groups: vec![Pubkey::new_unique()], + builder, + stake_ref: Pubkey::new_unique(), + spec_id: "top-of-book@v1.0.0".to_string(), + sla_hash: [9u8; 32], + committed_rate_bits_per_sec: ONE_GBPS, + status: FeedStatus::Retiring, + halted_by: Pubkey::default(), + retires_at, + }; + (feed_key, borsh::to_vec(&feed).unwrap()) +} + +async fn feed_of(banks_client: &mut BanksClient, feed: Pubkey) -> Feed { + get_account_data(banks_client, feed) + .await + .expect("the feed should exist") + .get_feed() + .expect("it should be a feed") +} + +/// Retiring an active feed starts the notice its seat holders are owed. +#[tokio::test] +async fn test_retiring_an_active_feed_starts_the_notice() { + let (mut banks_client, program_id, payer, globalstate, feed) = catalog_feed("leaving").await; + + let before = banks_client + .get_sysvar::() + .await + .expect("a clock") + .unix_timestamp; + + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::RetireFeed(FeedRetireArgs {}), + feed_accounts(feed, globalstate), + &payer, + ) + .await; + + let f = feed_of(&mut banks_client, feed).await; + assert_eq!(f.status, FeedStatus::Retiring); + assert!( + f.retires_at >= before + RETIREMENT_NOTICE_SECONDS, + "the notice runs a full thirty days from when retirement started" + ); +} + +/// A feed that never published owes nobody notice, so its wait is zero. +/// +/// It still has to be retirable: `DeleteFeed` refuses a staked feed, so refusing here as well +/// would leave a feed that never went live with no way out at all. +#[tokio::test] +async fn test_a_pending_feed_retires_without_waiting() { + let builder = test_payer(); + let (mut banks_client, program_id, payer, globalstate, feed) = + staked_feed_owned_by(&builder, "stillborn").await; + + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::RetireFeed(FeedRetireArgs {}), + feed_accounts(feed, globalstate), + &payer, + ) + .await; + + let f = feed_of(&mut banks_client, feed).await; + assert_eq!(f.status, FeedStatus::Retiring); + + let now = banks_client + .get_sysvar::() + .await + .expect("a clock") + .unix_timestamp; + assert!( + f.retires_at <= now, + "a feed that admitted no subscriber waits for nobody" + ); +} + +/// Retirement is terminal from the moment it starts. Neither lifecycle verb reopens it. +#[tokio::test] +async fn test_a_retiring_feed_neither_halts_nor_resumes() { + let program_id = Pubkey::new_unique(); + let (feed, data) = retiring_feed( + program_id, + "closing", + Pubkey::new_unique(), + Pubkey::default(), + i64::MAX, + ); + let (mut banks_client, payer, recent_blockhash) = + init_test_with_accounts(program_id, &[(feed, data)]).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::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)); + + let result = try_execute_and_get_error( + &mut banks_client, + program_id, + DoubleZeroInstruction::RetireFeed(FeedRetireArgs {}), + feed_accounts(feed, globalstate), + &payer, + &[], + ) + .await; + assert_custom_at_ix0(&result, custom_code(DoubleZeroError::FeedNotRetirable)); +} + +/// The notice is a promise, so finalizing before it elapses is refused. +#[tokio::test] +async fn test_finalizing_before_the_notice_elapses_is_refused() { + let program_id = Pubkey::new_unique(); + let (feed, data) = retiring_feed( + program_id, + "waiting", + Pubkey::new_unique(), + Pubkey::default(), + i64::MAX, + ); + let (mut banks_client, payer, recent_blockhash) = + init_test_with_accounts(program_id, &[(feed, data)]).await; + init_globalstate(&mut banks_client, program_id, &payer, recent_blockhash).await; + + let result = try_execute_and_get_error( + &mut banks_client, + program_id, + DoubleZeroInstruction::FinalizeFeedRetirement(FeedFinalizeRetirementArgs {}), + vec![AccountMeta::new(feed, false)], + &payer, + &[], + ) + .await; + assert_custom_at_ix0( + &result, + custom_code(DoubleZeroError::RetirementNoticeNotElapsed), + ); + assert_eq!( + feed_status(&mut banks_client, feed).await, + FeedStatus::Retiring + ); +} + +/// Once the notice has elapsed anyone may finish the retirement. +/// +/// Permissionless on purpose. The clock already decided, so this instruction can only agree with +/// it, and requiring an authority would let a feed sit in `Retiring` forever because whoever held +/// the key stopped caring. The signer here holds nothing. +#[tokio::test] +async fn test_anyone_finalizes_once_the_notice_has_elapsed() { + let program_id = Pubkey::new_unique(); + let (feed, data) = retiring_feed( + program_id, + "done", + Pubkey::new_unique(), + Pubkey::default(), + 0, + ); + let (mut banks_client, payer, recent_blockhash) = + init_test_with_accounts(program_id, &[(feed, data)]).await; + init_globalstate(&mut banks_client, program_id, &payer, recent_blockhash).await; + + let stranger = test_payer(); + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::FinalizeFeedRetirement(FeedFinalizeRetirementArgs {}), + vec![AccountMeta::new(feed, false)], + &stranger, + ) + .await; + + assert_eq!( + feed_status(&mut banks_client, feed).await, + FeedStatus::Retired + ); +} + +/// A feed that is not retiring has no retirement to finish. +#[tokio::test] +async fn test_finalizing_a_feed_that_is_not_retiring_is_refused() { + let (mut banks_client, program_id, payer, _globalstate, feed) = catalog_feed("running2").await; + + let result = try_execute_and_get_error( + &mut banks_client, + program_id, + DoubleZeroInstruction::FinalizeFeedRetirement(FeedFinalizeRetirementArgs {}), + vec![AccountMeta::new(feed, false)], + &payer, + &[], + ) + .await; + assert_custom_at_ix0(&result, custom_code(DoubleZeroError::FeedNotRetiring)); +} From f5d21f3c5048223e4a6d7ae8154e0f06116976dc Mon Sep 17 00:00:00 2001 From: Ben Marx Date: Thu, 10 Sep 2026 15:26:31 -0700 Subject: [PATCH 2/5] serviceability: format the lifecycle test the way CI does CI runs nightly rustfmt with `imports_granularity=Crate`; stable `cargo fmt` does not merge import paths, so a second `processors::` path passed locally and failed there. --- .../tests/feed_lifecycle_test.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/smartcontract/programs/doublezero-serviceability/tests/feed_lifecycle_test.rs b/smartcontract/programs/doublezero-serviceability/tests/feed_lifecycle_test.rs index e76537d542..4ddc00bfaa 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/feed_lifecycle_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/feed_lifecycle_test.rs @@ -7,11 +7,13 @@ use doublezero_serviceability::{ error::DoubleZeroError, instructions::DoubleZeroInstruction, pda::{get_feed_pda, get_globalstate_pda, get_stake_mirror_pda}, - processors::feed::retire::RETIREMENT_NOTICE_SECONDS, processors::{ feed::{ - create::FeedCreateArgs, finalize_retirement::FeedFinalizeRetirementArgs, - halt::FeedHaltArgs, resume::FeedResumeArgs, retire::FeedRetireArgs, + create::FeedCreateArgs, + finalize_retirement::FeedFinalizeRetirementArgs, + halt::FeedHaltArgs, + resume::FeedResumeArgs, + retire::{FeedRetireArgs, RETIREMENT_NOTICE_SECONDS}, }, globalstate::setfeatureflags::SetFeatureFlagsArgs, }, From 3e7dad10d15ed9b20893e2d4a22388e0b830d718 Mon Sep 17 00:00:00 2001 From: Ben Marx Date: Fri, 11 Sep 2026 13:05:44 -0700 Subject: [PATCH 3/5] serviceability: keep serving seat holders through the retirement notice `require_feed_admits` refused every status but `Active`, so the first instant of a notice cut off the holders the notice exists to protect. It now admits `Retiring`, and `SetAccessPassFeeds` refuses a new seat on a feed that is `Retiring` or `Retired`. Service continues, sales stop. The notice assertions pinned one side of the window only, so a clock change could not fail them. They now pin both. The fixture carries `Retiring` and a negative `retires_at`, which pins the two decoders a compiler cannot reach. --- CHANGELOG.md | 2 +- .../serviceability/tests/test_fixtures.py | 8 +- sdk/serviceability/testdata/fixtures/feed.bin | Bin 321 -> 321 bytes .../testdata/fixtures/feed.json | 12 +- .../fixtures/generate-fixtures/src/main.rs | 11 +- .../serviceability/tests/fixtures.test.ts | 8 +- .../src/processors/accesspass/set_feeds.rs | 19 +- .../src/processors/feed/mod.rs | 8 +- .../src/state/feed.rs | 48 ++++ .../tests/feed_lifecycle_test.rs | 261 +++++++++++++++++- 10 files changed, 363 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cc67931c3..e696ac87c6 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 - - `RetireFeed` (variant 122) and `FinalizeFeedRetirement` (variant 123), with a new `Retiring` status and a `retires_at` timestamp on `Feed`. Retiring starts a thirty-day notice to seat holders and is terminal: a retiring feed neither halts nor resumes, and the only state after it is `Retired`. A `Pending` feed retires with no wait, because the notice exists for seat holders and a feed that never went `Active` admitted none; it still has to be retirable, since `DeleteFeed` refuses a staked feed and refusing here too would leave it with no way out. Finalizing is permissionless: the clock has already decided, so requiring an authority would only let a feed sit in `Retiring` because whoever held the key stopped caring. `FeedRetired` is a `msg!` line rather than a new event mechanism, since this program has none and nothing consumes it yet. New errors `FeedNotRetirable` (126), `FeedNotRetiring` (127) and `RetirementNoticeNotElapsed` (128). + - `RetireFeed` (variant 122) and `FinalizeFeedRetirement` (variant 123), a `Retiring` status and a `retires_at` timestamp on `Feed`. Retiring starts a thirty-day notice and is terminal; a retiring feed keeps serving the seat holders it has and takes no new ones. Finalizing is permissionless once the notice elapses. New errors `FeedNotRetirable` (126), `FeedNotRetiring` (127) and `RetirementNoticeNotElapsed` (128). - `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) - New user accounts record the selected access-pass address, and deletion rejects another pass while preserving legacy address validation. diff --git a/sdk/serviceability/python/serviceability/tests/test_fixtures.py b/sdk/serviceability/python/serviceability/tests/test_fixtures.py index c57e34f6b7..80ccdee345 100644 --- a/sdk/serviceability/python/serviceability/tests/test_fixtures.py +++ b/sdk/serviceability/python/serviceability/tests/test_fixtures.py @@ -14,6 +14,7 @@ Exchange, FEED_STATUS_ACTIVE, FEED_STATUS_PENDING, + FEED_STATUS_RETIRING, Feed, GlobalConfig, GlobalState, @@ -627,6 +628,8 @@ def test_deserialize(self): "SlaHash": feed.sla_hash.hex(), "CommittedRateBitsPerSec": feed.committed_rate_bits_per_sec, "Status": feed.status, + "HaltedBy": feed.halted_by, + "RetiresAt": feed.retires_at, }, ) assert feed.account_type == 18 @@ -634,7 +637,10 @@ def test_deserialize(self): assert feed.code == "shreds" assert feed.name == "Shreds" assert len(feed.groups) == 2 - assert feed.status == FEED_STATUS_PENDING + assert feed.status == FEED_STATUS_RETIRING + # Negative on purpose: retires_at is an i64, and a positive value cannot tell a signed + # read from an unsigned one. + assert feed.retires_at == -1_764_547_200 def test_legacy_deserialize(self): # A feed written before RFC-28 ends after groups. The stake fields default, and the status diff --git a/sdk/serviceability/testdata/fixtures/feed.bin b/sdk/serviceability/testdata/fixtures/feed.bin index 2e2ab56967cdded6dc54a41ff752355e224231bf..022cc60b37ed53e82c288d4d8a65e6480b9e9752 100644 GIT binary patch delta 22 ccmX@ebdYI-1S2EMWJN{=jt14s)BXbi08h>dJOBUy delta 16 WcmX@ebdYI-1mnb1(TN3IKm-6WX9aNp diff --git a/sdk/serviceability/testdata/fixtures/feed.json b/sdk/serviceability/testdata/fixtures/feed.json index aac6a7db5c..8accb74a7f 100644 --- a/sdk/serviceability/testdata/fixtures/feed.json +++ b/sdk/serviceability/testdata/fixtures/feed.json @@ -74,8 +74,18 @@ }, { "name": "Status", - "value": "0", + "value": "4", "typ": "u8" + }, + { + "name": "HaltedBy", + "value": "11111111111111111111111111111111", + "typ": "pubkey" + }, + { + "name": "RetiresAt", + "value": "-1764547200", + "typ": "i64" } ] } \ No newline at end of file diff --git a/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs b/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs index 1a9d841850..0d1d6bf920 100644 --- a/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs +++ b/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs @@ -1518,9 +1518,12 @@ fn generate_feed(dir: &Path) { spec_id: "top-of-book@v1.0.0".into(), sla_hash: [0xE6; 32], committed_rate_bits_per_sec: 1_000_000_000, - status: FeedStatus::Pending, + status: FeedStatus::Retiring, halted_by: Pubkey::default(), - retires_at: 0, + // A distinct nonzero timestamp, so a decoder that reads the wrong offset, the wrong width + // or drops the field cannot pass. Negative, because `retires_at` is an i64 and a positive + // value cannot tell a signed read from an unsigned one. + retires_at: -1_764_547_200, }; let data = borsh::to_vec(&val).unwrap(); @@ -1543,7 +1546,9 @@ fn generate_feed(dir: &Path) { FieldValue { name: "SpecId".into(), value: "top-of-book@v1.0.0".into(), typ: "string".into() }, FieldValue { name: "SlaHash".into(), value: "e6".repeat(32), typ: "string".into() }, FieldValue { name: "CommittedRateBitsPerSec".into(), value: "1000000000".into(), typ: "u64".into() }, - FieldValue { name: "Status".into(), value: "0".into(), typ: "u8".into() }, + FieldValue { name: "Status".into(), value: "4".into(), typ: "u8".into() }, + FieldValue { name: "HaltedBy".into(), value: pubkey_bs58(&Pubkey::default()), typ: "pubkey".into() }, + FieldValue { name: "RetiresAt".into(), value: "-1764547200".into(), typ: "i64".into() }, ], }; diff --git a/sdk/serviceability/typescript/serviceability/tests/fixtures.test.ts b/sdk/serviceability/typescript/serviceability/tests/fixtures.test.ts index 7f3d3d7c03..24ef9866da 100644 --- a/sdk/serviceability/typescript/serviceability/tests/fixtures.test.ts +++ b/sdk/serviceability/typescript/serviceability/tests/fixtures.test.ts @@ -22,6 +22,7 @@ import { deserializeFeed, FEED_STATUS_ACTIVE, FEED_STATUS_PENDING, + FEED_STATUS_RETIRING, } from "../state.js"; const FIXTURES_DIR = join( @@ -647,6 +648,8 @@ describe("Feed fixture", () => { SlaHash: Buffer.from(feed.slaHash).toString("hex"), CommittedRateBitsPerSec: feed.committedRateBitsPerSec, Status: feed.status, + HaltedBy: feed.haltedBy, + RetiresAt: feed.retiresAt, }); expect(feed.accountType).toBe(18); @@ -654,7 +657,10 @@ describe("Feed fixture", () => { expect(feed.code).toBe("shreds"); expect(feed.name).toBe("Shreds"); expect(feed.groups).toHaveLength(2); - expect(feed.status).toBe(FEED_STATUS_PENDING); + expect(feed.status).toBe(FEED_STATUS_RETIRING); + // Negative on purpose: it is the only value that makes the asIntN reinterpretation in the + // decoder meaningful. Read as unsigned this would be a very large positive number. + expect(feed.retiresAt).toBe(-1764547200n); }); // A feed written before RFC-28 ends after groups. The stake fields default, and the status reads diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/accesspass/set_feeds.rs b/smartcontract/programs/doublezero-serviceability/src/processors/accesspass/set_feeds.rs index 741b069b60..9bcc39ab0e 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/accesspass/set_feeds.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/accesspass/set_feeds.rs @@ -5,7 +5,7 @@ use crate::{ serializer::try_acc_write, state::{ accesspass::{AccessPass, AccessPassType, FeedSeat}, - feed::Feed, + feed::{Feed, FeedStatus}, globalstate::GlobalState, permission::permission_flags, }, @@ -146,7 +146,22 @@ pub fn process_set_access_pass_feeds( } // Confirm the account really is a Feed (owner checked above, discriminator here). - Feed::try_from(*feed_account)?; + let feed = Feed::try_from(*feed_account)?; + + // No new seat on a feed that is closing. `require_feed_admits` lets a `Retiring` feed keep + // serving the holders it already has, which is what the notice is for; selling a seat into + // that window would hand someone thirty days of a service that is ending. A seat already on + // the pass is untouched, so this refuses adding one rather than keeping one. + if matches!(feed.status, FeedStatus::Retiring | FeedStatus::Retired) + && !prior_seats.iter().any(|s| s.feed_key == feed_key) + { + msg!( + "Feed {} is {}, so it takes no new seat", + feed_key, + feed.status + ); + return Err(DoubleZeroError::FeedNotActive.into()); + } let current_users = prior_seats .iter() diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/feed/mod.rs b/smartcontract/programs/doublezero-serviceability/src/processors/feed/mod.rs index 26e0d48c15..b82a617ba5 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/feed/mod.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/feed/mod.rs @@ -114,11 +114,17 @@ pub fn enforce_feed_metro_gate( /// status changes, so retirement and slashing need no sweep over the access passes that already /// carry a seat for it. /// +/// `Retiring` admits, and that is the point of the state. RFC-28 gives seat holders thirty days +/// notice *before publication stops*, so a feed serves its existing holders throughout. Refusing +/// here would lock out anyone who reconnects on the first day of the notice, which would leave the +/// notice protecting only the population that cannot come back. A new seat is refused instead +/// where seats are provisioned, so nobody buys into a feed that is closing. +/// /// Call this where a seat is spent, never from the shared coverage check: `unsubscribe_feed` runs /// through that too, and gating there would leave a user holding a seat on a retired feed with no /// way to release it. pub fn require_feed_admits(feed_key: &Pubkey, feed: &Feed) -> Result<(), DoubleZeroError> { - if feed.status != FeedStatus::Active { + if !matches!(feed.status, FeedStatus::Active | FeedStatus::Retiring) { msg!( "Feed {} is {}, so it admits no subscribers", feed_key, diff --git a/smartcontract/programs/doublezero-serviceability/src/state/feed.rs b/smartcontract/programs/doublezero-serviceability/src/state/feed.rs index 0ac70cc621..7cb695eac9 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/feed.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/feed.rs @@ -309,4 +309,52 @@ mod tests { let data = borsh::to_vec(&val).unwrap(); assert!(Feed::try_from(&data[..]).is_err()); } + + /// `Retiring` must serialize as byte 4 and survive a round trip, and `retires_at` must survive + /// a negative value. + /// + /// The doc on `Retiring` calls discriminant 4 the load-bearing compatibility decision, since + /// renumbering `Halted` or `Retired` would reinterpret every stored feed. Nothing pinned that + /// byte, so this does, at the byte rather than through the enum. + #[test] + fn test_retiring_round_trips_and_holds_discriminant_four() { + let mut feed = feed_with(Pubkey::new_unique(), vec![Pubkey::new_unique()]); + feed.status = FeedStatus::Retiring; + feed.halted_by = Pubkey::new_unique(); + feed.retires_at = -1_764_547_200; + + let bytes = borsh::to_vec(&feed).unwrap(); + let decoded = Feed::try_from(&bytes[..]).unwrap(); + assert_eq!(decoded, feed); + assert_eq!(decoded.status, FeedStatus::Retiring); + assert_eq!(decoded.retires_at, -1_764_547_200); + assert_eq!(decoded.halted_by, feed.halted_by); + + // The status byte sits immediately before `halted_by` and `retires_at`, the last three + // fields, so index from the end rather than counting the variable-length ones. + let status_index = bytes.len() - 32 - 8 - 1; + assert_eq!( + bytes[status_index], 4, + "Retiring is discriminant 4; changing it reinterprets every stored feed" + ); + } + + /// Every status round trips at its own discriminant, so none can be renumbered quietly. + #[test] + fn test_every_status_holds_its_discriminant() { + for (status, byte) in [ + (FeedStatus::Pending, 0u8), + (FeedStatus::Active, 1), + (FeedStatus::Halted, 2), + (FeedStatus::Retired, 3), + (FeedStatus::Retiring, 4), + ] { + let mut feed = feed_with(Pubkey::new_unique(), vec![]); + feed.status = status; + let bytes = borsh::to_vec(&feed).unwrap(); + let status_index = bytes.len() - 32 - 8 - 1; + assert_eq!(bytes[status_index], byte, "{status} must stay byte {byte}"); + assert_eq!(Feed::try_from(&bytes[..]).unwrap().status, status); + } + } } diff --git a/smartcontract/programs/doublezero-serviceability/tests/feed_lifecycle_test.rs b/smartcontract/programs/doublezero-serviceability/tests/feed_lifecycle_test.rs index 4ddc00bfaa..13f59f7c09 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/feed_lifecycle_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/feed_lifecycle_test.rs @@ -602,12 +602,32 @@ async fn test_retiring_an_active_feed_starts_the_notice() { ) .await; + let after = banks_client + .get_sysvar::() + .await + .expect("a clock") + .unix_timestamp; + let f = feed_of(&mut banks_client, feed).await; assert_eq!(f.status, FeedStatus::Retiring); + + // Pinned, not bounded. A one-sided `>=` is satisfied by any notice longer than thirty days, + // so it cannot tell the intended value from `RETIREMENT_NOTICE_SECONDS * 1000`. The clock is + // read either side of the call, so the computed value has to land in that window and nowhere + // else. assert!( - f.retires_at >= before + RETIREMENT_NOTICE_SECONDS, - "the notice runs a full thirty days from when retirement started" + (before + RETIREMENT_NOTICE_SECONDS..=after + RETIREMENT_NOTICE_SECONDS) + .contains(&f.retires_at), + "retires_at {} is not now plus the notice, which was between {} and {}", + f.retires_at, + before + RETIREMENT_NOTICE_SECONDS, + after + RETIREMENT_NOTICE_SECONDS ); + + // The rest of the feed is untouched, and `halted_by` in particular is neither set nor cleared + // by retiring. + assert_eq!(f.halted_by, Pubkey::default()); + assert_eq!(f.name, "Catalog"); } /// A feed that never published owes nobody notice, so its wait is zero. @@ -620,6 +640,12 @@ async fn test_a_pending_feed_retires_without_waiting() { let (mut banks_client, program_id, payer, globalstate, feed) = staked_feed_owned_by(&builder, "stillborn").await; + let before = banks_client + .get_sysvar::() + .await + .expect("a clock") + .unix_timestamp; + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; execute_transaction( &mut banks_client, @@ -639,9 +665,14 @@ async fn test_a_pending_feed_retires_without_waiting() { .await .expect("a clock") .unix_timestamp; + + // Pinned rather than bounded: `<= now` is satisfied by the `i64` default, so it cannot tell a + // zero wait from no notice written at all. A pending feed's notice is *now*, so it is positive + // and within the window the clock allows. assert!( - f.retires_at <= now, - "a feed that admitted no subscriber waits for nobody" + f.retires_at > 0 && (before..=now).contains(&f.retires_at), + "retires_at {} is not the current time, which was between {before} and {now}", + f.retires_at ); } @@ -782,3 +813,225 @@ async fn test_finalizing_a_feed_that_is_not_retiring_is_refused() { .await; assert_custom_at_ix0(&result, custom_code(DoubleZeroError::FeedNotRetiring)); } + +/// Retire then finalize, so the timestamp `retire` computes is the one `finalize` compares. +/// +/// The pending path makes this free: its notice is zero, so no clock warp is needed and the two +/// instructions meet for real rather than through a seeded timestamp. +#[tokio::test] +async fn test_a_pending_feed_retires_and_finalizes_end_to_end() { + let builder = test_payer(); + let (mut banks_client, program_id, payer, globalstate, feed) = + staked_feed_owned_by(&builder, "endtoend").await; + + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::RetireFeed(FeedRetireArgs {}), + feed_accounts(feed, globalstate), + &payer, + ) + .await; + assert_eq!( + feed_status(&mut banks_client, feed).await, + FeedStatus::Retiring + ); + + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::FinalizeFeedRetirement(FeedFinalizeRetirementArgs {}), + vec![AccountMeta::new(feed, false)], + &payer, + ) + .await; + + let f = feed_of(&mut banks_client, feed).await; + assert_eq!(f.status, FeedStatus::Retired); + // Finalizing changes the status and nothing else. `retires_at` is the record of the notice + // that was given, so it stays. + assert!(f.retires_at > 0, "the notice it was given is not erased"); + assert_eq!(f.halted_by, Pubkey::default()); +} + +/// A feed retired from a real thirty-day notice cannot be finalized early. +/// +/// The not-yet case against a notice `retire` computed, rather than a seeded `i64::MAX`. +#[tokio::test] +async fn test_a_real_notice_cannot_be_finalized_early() { + let (mut banks_client, program_id, payer, globalstate, feed) = catalog_feed("earlybird").await; + + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::RetireFeed(FeedRetireArgs {}), + feed_accounts(feed, globalstate), + &payer, + ) + .await; + + let result = try_execute_and_get_error( + &mut banks_client, + program_id, + DoubleZeroInstruction::FinalizeFeedRetirement(FeedFinalizeRetirementArgs {}), + vec![AccountMeta::new(feed, false)], + &payer, + &[], + ) + .await; + assert_custom_at_ix0( + &result, + custom_code(DoubleZeroError::RetirementNoticeNotElapsed), + ); +} + +/// Halted to Retiring, the one supported transition that nothing else covers, and it must keep +/// the record of who halted. +#[tokio::test] +async fn test_a_halted_feed_retires_and_keeps_its_halter() { + let builder = test_payer(); + let operator = Pubkey::new_unique(); + let (mut banks_client, program_id, payer, globalstate, feed, _mirror) = + cluster_with_halted_feed("halted2retiring", builder.pubkey(), operator).await; + + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::RetireFeed(FeedRetireArgs {}), + feed_accounts(feed, globalstate), + &payer, + ) + .await; + + let f = feed_of(&mut banks_client, feed).await; + assert_eq!(f.status, FeedStatus::Retiring); + assert!( + f.retires_at > 0, + "a halted feed had seat holders, so it owes notice" + ); + assert_eq!( + f.halted_by, operator, + "retiring does not erase who halted; that record outlives the halt" + ); +} + +/// Retirement is the most irreversible transition here, so it has to be gated at all. +#[tokio::test] +async fn test_a_stranger_cannot_retire_a_feed() { + let builder = test_payer(); + let (mut banks_client, program_id, _payer, globalstate, feed) = + staked_feed_owned_by(&builder, "notyours").await; + + 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::RetireFeed(FeedRetireArgs {}), + feed_accounts(feed, globalstate), + &stranger, + &[], + ) + .await; + assert_custom_at_ix0(&result, custom_code(DoubleZeroError::NotAllowed)); + assert_eq!( + feed_status(&mut banks_client, feed).await, + FeedStatus::Pending + ); + + // The feed's own builder is authorized, which is what makes the refusal above about the + // signer rather than about the instruction being wired wrong. + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::RetireFeed(FeedRetireArgs {}), + feed_accounts(feed, globalstate), + &builder, + ) + .await; + assert_eq!( + feed_status(&mut banks_client, feed).await, + FeedStatus::Retiring + ); +} + +/// Retired is terminal: nothing moves a feed out of it, and nothing retires it again. +#[tokio::test] +async fn test_retired_is_terminal() { + let builder = test_payer(); + let (mut banks_client, program_id, payer, globalstate, feed) = + staked_feed_owned_by(&builder, "finished").await; + + for ix in [ + DoubleZeroInstruction::RetireFeed(FeedRetireArgs {}), + DoubleZeroInstruction::FinalizeFeedRetirement(FeedFinalizeRetirementArgs {}), + ] { + let accounts = match ix { + DoubleZeroInstruction::FinalizeFeedRetirement(_) => vec![AccountMeta::new(feed, false)], + _ => feed_accounts(feed, globalstate), + }; + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + ix, + accounts, + &payer, + ) + .await; + } + assert_eq!( + feed_status(&mut banks_client, feed).await, + FeedStatus::Retired + ); + + // Every verb, refused, each by its own error rather than one shared failure. + for (ix, expected) in [ + ( + DoubleZeroInstruction::RetireFeed(FeedRetireArgs {}), + DoubleZeroError::FeedNotRetirable, + ), + ( + DoubleZeroInstruction::HaltFeed(FeedHaltArgs {}), + DoubleZeroError::FeedNotHaltable, + ), + ( + DoubleZeroInstruction::ResumeFeed(FeedResumeArgs {}), + DoubleZeroError::FeedNotResumable, + ), + ] { + let result = try_execute_and_get_error( + &mut banks_client, + program_id, + ix, + feed_accounts(feed, globalstate), + &payer, + &[], + ) + .await; + assert_custom_at_ix0(&result, custom_code(expected)); + } + + let result = try_execute_and_get_error( + &mut banks_client, + program_id, + DoubleZeroInstruction::FinalizeFeedRetirement(FeedFinalizeRetirementArgs {}), + vec![AccountMeta::new(feed, false)], + &payer, + &[], + ) + .await; + assert_custom_at_ix0(&result, custom_code(DoubleZeroError::FeedNotRetiring)); +} From ec84f65b1282f7beacffdca65099a0225de30121 Mon Sep 17 00:00:00 2001 From: Ben Marx Date: Fri, 11 Sep 2026 13:14:08 -0700 Subject: [PATCH 4/5] serviceability: pin both halves of the retirement admission rule Neither half had a test. Reverting `require_feed_admits` to `Active` only, and dropping the `SetAccessPassFeeds` guard, both left the suite green. One test per side now fails on that mutation: a seat holder subscribes to a retiring feed, and a newcomer is refused a seat on one while the holder keeps theirs. --- .../tests/feed_metro_gate_test.rs | 62 +++++- .../tests/set_access_pass_feeds_test.rs | 186 +++++++++++++++++- 2 files changed, 245 insertions(+), 3 deletions(-) diff --git a/smartcontract/programs/doublezero-serviceability/tests/feed_metro_gate_test.rs b/smartcontract/programs/doublezero-serviceability/tests/feed_metro_gate_test.rs index 87dfaf6e12..9a89b37bb5 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/feed_metro_gate_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/feed_metro_gate_test.rs @@ -22,7 +22,7 @@ use doublezero_serviceability::{ contributor::create::ContributorCreateArgs, device::{create::DeviceCreateArgs, update::DeviceUpdateArgs}, exchange::create::ExchangeCreateArgs, - feed::create::FeedCreateArgs, + feed::{create::FeedCreateArgs, retire::FeedRetireArgs}, globalstate::setfeatureflags::SetFeatureFlagsArgs, location::create::LocationCreateArgs, multicastgroup::create::MulticastGroupCreateArgs, @@ -34,6 +34,7 @@ use doublezero_serviceability::{ accounttype::AccountType, device::DeviceType, feature_flags::FeatureFlag, + feed::FeedStatus, stake_mirror::{StakeMirror, StakeTier}, user::{UserCYOA, UserStatus, UserType}, }, @@ -817,7 +818,7 @@ async fn test_non_active_feed_admits_no_subscriber() { let (exchange, mgroup) = (f.exchange_pubkey, f.mgroup_pubkey); let feed = create_staked_feed(&mut f, "pending", exchange, vec![mgroup]).await; - // The oracle sold a seat on it. Pre-selling a Pending feed is legitimate; connecting is not. + // A seat provisioner sold a seat on it. Pre-selling a Pending feed is legitimate; connecting is not. set_pass_feeds( &mut f, vec![FeedSeat { @@ -845,3 +846,60 @@ async fn test_non_active_feed_admits_no_subscriber() { .unwrap(); assert_eq!(pass.feed_seats()[0].current_users, 0); } + +/// A retiring feed still admits its subscribers, which is the whole point of the notice. RFC-28 +/// gives seat holders thirty days before publication stops; refusing them here would end the +/// service on the day the notice starts and leave the notice protecting nobody who can reconnect. +/// +/// The companion half, that no new seat is sold during the notice, lives in +/// `set_access_pass_feeds_test.rs`. +#[tokio::test] +async fn test_a_retiring_feed_still_admits_its_seat_holders() { + let mut f = setup_feed_fixture([100, 0, 0, 27]).await; + let (exchange, mgroup) = (f.exchange_pubkey, f.mgroup_pubkey); + let feed = create_feed(&mut f, "closing", exchange, vec![mgroup]).await; + set_pass_feeds( + &mut f, + vec![FeedSeat { + feed_key: feed, + max_users: 2, + max_future_users: 2, + current_users: 0, + anniversary_day: 15, + window_end: TEST_WINDOW_END, + terminates_at: TEST_TERMINATES_AT, + }], + ) + .await; + + let recent_blockhash = wait_for_new_blockhash(&mut f.banks_client).await; + execute_transaction( + &mut f.banks_client, + recent_blockhash, + f.program_id, + DoubleZeroInstruction::RetireFeed(FeedRetireArgs {}), + vec![ + AccountMeta::new(feed, false), + AccountMeta::new(f.globalstate_pubkey, false), + ], + &f.payer, + ) + .await; + let retiring = get_account_data(&mut f.banks_client, feed) + .await + .expect("feed exists") + .get_feed() + .unwrap(); + assert_eq!(retiring.status, FeedStatus::Retiring); + + try_subscribe_with_feed(&mut f, feed) + .await + .expect("a retiring feed should still admit its seat holders"); + + let pass = get_account_data(&mut f.banks_client, f.accesspass_pubkey) + .await + .unwrap() + .get_accesspass() + .unwrap(); + assert_eq!(pass.feed_seats()[0].current_users, 1); +} diff --git a/smartcontract/programs/doublezero-serviceability/tests/set_access_pass_feeds_test.rs b/smartcontract/programs/doublezero-serviceability/tests/set_access_pass_feeds_test.rs index 0b8175096c..c0e618343e 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/set_access_pass_feeds_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/set_access_pass_feeds_test.rs @@ -8,7 +8,7 @@ use doublezero_serviceability::{ set::SetAccessPassArgs, set_feeds::{FeedSeatConfig, SetAccessPassFeedsArgs, MAX_ACCESS_PASS_FEEDS}, }, - feed::create::FeedCreateArgs, + feed::{create::FeedCreateArgs, retire::FeedRetireArgs}, }, state::{ accesspass::{AccessPass, AccessPassStatus, AccessPassType, FeedSeat}, @@ -968,3 +968,187 @@ async fn test_cannot_set_zero_max_users() { .await; assert_custom_at_ix0(&result, custom_code(DoubleZeroError::FeedMaxUsersZero)); } + +/// The seat config the feed tests below all use. Only the feed accounts differ between calls, so +/// the config carries no meaning of its own. +fn seat_config() -> FeedSeatConfig { + FeedSeatConfig { + max_users: 5, + max_future_users: 5, + anniversary_day: 15, + window_end: TEST_WINDOW_END, + terminates_at: TEST_TERMINATES_AT, + } +} + +/// A retiring feed sells no new seat, and keeps the ones already sold. +/// +/// The two halves belong in one test because the guard distinguishes them by the pass it is +/// handed, not by the feed: the same `Retiring` feed is refused for one pass and kept for another +/// in the same bank. +#[tokio::test] +async fn test_a_retiring_feed_keeps_its_seats_and_sells_no_new_one() { + let (mut banks_client, program_id, payer, recent_blockhash) = init_test().await; + let globalstate_pubkey = + init_globalstate(&mut banks_client, program_id, &payer, recent_blockhash).await; + + let live = create_feed( + &mut banks_client, + program_id, + globalstate_pubkey, + &payer, + recent_blockhash, + "live", + ) + .await; + let closing = create_feed( + &mut banks_client, + program_id, + globalstate_pubkey, + &payer, + recent_blockhash, + "clsg", + ) + .await; + + let holder_ip = Ipv4Addr::new(100, 0, 0, 1); + let holder_payer = Pubkey::new_unique(); + let holder_pass = create_edge_seat_pass( + &mut banks_client, + program_id, + globalstate_pubkey, + &payer, + recent_blockhash, + holder_ip, + holder_payer, + AccessPassType::EdgeSeat(vec![]), + ) + .await; + + // The holder buys both seats while the feed is still open. + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::SetAccessPassFeeds(SetAccessPassFeedsArgs { + client_ip: holder_ip, + user_payer: holder_payer, + feeds: vec![seat_config(), seat_config()], + }), + vec![ + AccountMeta::new(holder_pass, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(live, false), + AccountMeta::new(closing, false), + ], + &payer, + ) + .await; + + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::RetireFeed(FeedRetireArgs {}), + vec![ + AccountMeta::new(closing, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Re-provisioning the holder's pass still names the retiring feed, and keeps it. This is the + // path that raises a seat cap or moves a billing window, so refusing it would strand the + // holder's other seats behind the one that is closing. + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::SetAccessPassFeeds(SetAccessPassFeedsArgs { + client_ip: holder_ip, + user_payer: holder_payer, + feeds: vec![seat_config(), seat_config()], + }), + vec![ + AccountMeta::new(holder_pass, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(live, false), + AccountMeta::new(closing, false), + ], + &payer, + ) + .await; + let accesspass = read_accesspass(&mut banks_client, holder_pass).await; + let AccessPassType::EdgeSeat(seats) = &accesspass.accesspass_type else { + panic!("expected an edge seat pass"); + }; + assert_eq!( + seats.iter().map(|s| s.feed_key).collect::>(), + vec![live, closing] + ); + + // A pass that never held a seat on the retiring feed cannot buy one now. + let newcomer_ip = Ipv4Addr::new(100, 0, 0, 2); + let newcomer_payer = Pubkey::new_unique(); + let newcomer_pass = create_edge_seat_pass( + &mut banks_client, + program_id, + globalstate_pubkey, + &payer, + recent_blockhash, + newcomer_ip, + newcomer_payer, + AccessPassType::EdgeSeat(vec![]), + ) + .await; + + let result = try_execute_and_get_error( + &mut banks_client, + program_id, + DoubleZeroInstruction::SetAccessPassFeeds(SetAccessPassFeedsArgs { + client_ip: newcomer_ip, + user_payer: newcomer_payer, + feeds: vec![seat_config()], + }), + vec![ + AccountMeta::new(newcomer_pass, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(closing, false), + ], + &payer, + ) + .await; + assert_custom_at_ix0(&result, custom_code(DoubleZeroError::FeedNotActive)); + + // The live feed is still on sale, so the refusal is about the retiring feed and not about the + // newcomer's pass. + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::SetAccessPassFeeds(SetAccessPassFeedsArgs { + client_ip: newcomer_ip, + user_payer: newcomer_payer, + feeds: vec![seat_config()], + }), + vec![ + AccountMeta::new(newcomer_pass, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(live, false), + ], + &payer, + ) + .await; + let accesspass = read_accesspass(&mut banks_client, newcomer_pass).await; + let AccessPassType::EdgeSeat(seats) = &accesspass.accesspass_type else { + panic!("expected an edge seat pass"); + }; + assert_eq!( + seats.iter().map(|s| s.feed_key).collect::>(), + vec![live] + ); +} From 0ed11387d6760c7c664655896cdabf09a53741b9 Mon Sep 17 00:00:00 2001 From: Ben Marx Date: Fri, 11 Sep 2026 14:06:13 -0700 Subject: [PATCH 5/5] serviceability: close the two ways around a retirement notice `DeleteFeed` closed a retiring catalog feed, which ended the notice the same minute it started. It now refuses one until the retirement is finalized, with `RetiringFeedCannotBeDeleted` (129). A retired feed still deletes, or a finished retirement would strand the account. The instruction crate stopped at `resume_feed`, so a caller could not build either retirement transaction without hand-encoding it. `finalize_feed_retirement` takes `[feed]` and no globalstate, because it is permissionless and there is no authority to check against. --- CHANGELOG.md | 2 +- .../src/feed.rs | 57 +++++++++++- .../doublezero-serviceability/src/error.rs | 4 + .../src/processors/feed/delete.rs | 17 +++- .../tests/feed_lifecycle_test.rs | 89 +++++++++++++++++++ 5 files changed, 165 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e696ac87c6..ffc530927a 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 - - `RetireFeed` (variant 122) and `FinalizeFeedRetirement` (variant 123), a `Retiring` status and a `retires_at` timestamp on `Feed`. Retiring starts a thirty-day notice and is terminal; a retiring feed keeps serving the seat holders it has and takes no new ones. Finalizing is permissionless once the notice elapses. New errors `FeedNotRetirable` (126), `FeedNotRetiring` (127) and `RetirementNoticeNotElapsed` (128). + - `RetireFeed` (variant 122) and `FinalizeFeedRetirement` (variant 123), a `Retiring` status and a `retires_at` timestamp on `Feed`. Retiring starts a thirty-day notice and is terminal; a retiring feed keeps serving the seat holders it has and takes no new ones. Finalizing is permissionless once the notice elapses. `DeleteFeed` refuses a retiring feed, so the notice cannot be cut short by closing the account. New errors `FeedNotRetirable` (126), `FeedNotRetiring` (127), `RetirementNoticeNotElapsed` (128) and `RetiringFeedCannotBeDeleted` (129). `retire_feed` and `finalize_feed_retirement` join the instruction crate's feed builders. - `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) - New user accounts record the selected access-pass address, and deletion rejects another pass while preserving legacy address validation. diff --git a/crates/doublezero-serviceability-instruction/src/feed.rs b/crates/doublezero-serviceability-instruction/src/feed.rs index 7c9bbe59ee..fa26ea4a9c 100644 --- a/crates/doublezero-serviceability-instruction/src/feed.rs +++ b/crates/doublezero-serviceability-instruction/src/feed.rs @@ -30,8 +30,9 @@ use doublezero_serviceability::{ instructions::DoubleZeroInstruction, pda::{get_feed_pda, get_globalstate_pda, get_stake_mirror_pda}, processors::feed::{ - create::FeedCreateArgs, delete::FeedDeleteArgs, halt::FeedHaltArgs, resume::FeedResumeArgs, - update::FeedUpdateArgs, + create::FeedCreateArgs, delete::FeedDeleteArgs, + finalize_retirement::FeedFinalizeRetirementArgs, halt::FeedHaltArgs, + resume::FeedResumeArgs, retire::FeedRetireArgs, update::FeedUpdateArgs, }, }; use solana_program::{ @@ -158,6 +159,40 @@ pub fn resume_feed( ix } +/// `RetireFeed` (variant 122). Accounts: `[feed, globalstate]`. +/// +/// Starts the notice seat holders are owed and stops new seats being sold. Signed by the keys +/// `halt_feed` accepts, the feed's own builder included, because a builder that wants out of +/// running a feed should not need an operator to stop. +/// +/// This does not retire the feed. `finalize_feed_retirement` does, once the notice elapses. +pub fn retire_feed(program_id: &Pubkey, payer: &Pubkey, feed: &Pubkey) -> Instruction { + let (globalstate, _) = get_globalstate_pda(program_id); + common::build_with_permission( + program_id, + DoubleZeroInstruction::RetireFeed(FeedRetireArgs {}), + vec![ + AccountMeta::new(*feed, false), + AccountMeta::new(globalstate, false), + ], + payer, + ) +} + +/// `FinalizeFeedRetirement` (variant 123). Accounts: `[feed]`. +/// +/// Moves a feed from `Retiring` to `Retired` once its notice has elapsed. Permissionless, and so +/// on the no-permission path: the clock decides the outcome, and this instruction can only agree +/// with it. No globalstate either, because there is no authority to check against. +pub fn finalize_feed_retirement(program_id: &Pubkey, payer: &Pubkey, feed: &Pubkey) -> Instruction { + common::build( + program_id, + DoubleZeroInstruction::FinalizeFeedRetirement(FeedFinalizeRetirementArgs {}), + vec![AccountMeta::new(*feed, false)], + payer, + ) +} + #[cfg(test)] mod tests { use super::*; @@ -281,6 +316,24 @@ mod tests { assert_eq!(resume.data[0], 121); assert_eq!(resume.accounts, expected); + let retire = retire_feed(&pid, &payer, &feed); + assert_eq!(retire.data[0], 122); + assert_eq!(retire.accounts, expected); + + // Finalize is permissionless, so it carries no globalstate: nothing about it is checked + // against an authority, and sending one would ask a caller for an account the processor + // never reads. + let finalize = finalize_feed_retirement(&pid, &payer, &feed); + assert_eq!(finalize.data[0], 123); + assert_eq!( + finalize.accounts, + vec![ + AccountMeta::new(feed, false), + AccountMeta::new(payer, true), + AccountMeta::new(system_program::ID, false), + ] + ); + // 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(); diff --git a/smartcontract/programs/doublezero-serviceability/src/error.rs b/smartcontract/programs/doublezero-serviceability/src/error.rs index d28db4a2d8..1924f9abc4 100644 --- a/smartcontract/programs/doublezero-serviceability/src/error.rs +++ b/smartcontract/programs/doublezero-serviceability/src/error.rs @@ -267,6 +267,8 @@ pub enum DoubleZeroError { FeedNotRetiring, // variant 127 #[error("The retirement notice has not elapsed")] RetirementNoticeNotElapsed, // variant 128 + #[error("A retiring feed must finish its notice before it can be deleted")] + RetiringFeedCannotBeDeleted, // variant 129 } impl From for ProgramError { @@ -401,6 +403,7 @@ impl From for ProgramError { DoubleZeroError::FeedNotRetirable => ProgramError::Custom(126), DoubleZeroError::FeedNotRetiring => ProgramError::Custom(127), DoubleZeroError::RetirementNoticeNotElapsed => ProgramError::Custom(128), + DoubleZeroError::RetiringFeedCannotBeDeleted => ProgramError::Custom(129), } } } @@ -536,6 +539,7 @@ impl From for DoubleZeroError { 126 => DoubleZeroError::FeedNotRetirable, 127 => DoubleZeroError::FeedNotRetiring, 128 => DoubleZeroError::RetirementNoticeNotElapsed, + 129 => DoubleZeroError::RetiringFeedCannotBeDeleted, _ => DoubleZeroError::Custom(e), } } diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/feed/delete.rs b/smartcontract/programs/doublezero-serviceability/src/processors/feed/delete.rs index 211f0a11ed..ddba3c0166 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/feed/delete.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/feed/delete.rs @@ -2,7 +2,11 @@ use crate::{ authorize::authorize, error::DoubleZeroError, serializer::try_acc_close, - state::{feed::Feed, globalstate::GlobalState, permission::permission_flags}, + state::{ + feed::{Feed, FeedStatus}, + globalstate::GlobalState, + permission::permission_flags, + }, }; use borsh::BorshSerialize; use borsh_incremental::BorshDeserializeIncremental; @@ -65,6 +69,17 @@ pub fn process_delete_feed( return Err(DoubleZeroError::StakedFeedCannotBeDeleted.into()); } + // A feed under notice is not a catalog entry to drop either. Closing it here would end the + // thirty days seat holders were promised, and the promise is the only thing `Retiring` means. + // Finalize it first: `Retired` deletes like anything else. + if feed.status == FeedStatus::Retiring { + msg!( + "Feed {} is retiring and cannot be deleted until its notice elapses", + feed_account.key + ); + return Err(DoubleZeroError::RetiringFeedCannotBeDeleted.into()); + } + msg!("Deleted feed: {}", feed_account.key); try_acc_close(feed_account, payer_account)?; diff --git a/smartcontract/programs/doublezero-serviceability/tests/feed_lifecycle_test.rs b/smartcontract/programs/doublezero-serviceability/tests/feed_lifecycle_test.rs index 13f59f7c09..4802f6670f 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/feed_lifecycle_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/feed_lifecycle_test.rs @@ -10,6 +10,7 @@ use doublezero_serviceability::{ processors::{ feed::{ create::FeedCreateArgs, + delete::FeedDeleteArgs, finalize_retirement::FeedFinalizeRetirementArgs, halt::FeedHaltArgs, resume::FeedResumeArgs, @@ -1035,3 +1036,91 @@ async fn test_retired_is_terminal() { .await; assert_custom_at_ix0(&result, custom_code(DoubleZeroError::FeedNotRetiring)); } + +/// Deleting a feed cannot shortcut its notice. +/// +/// `DeleteFeed` already refuses a staked feed, so this is about the catalog ones. Without the +/// guard an authority retires a feed, tells its holders they have thirty days, and closes the +/// account the same minute. The notice is the only thing `Retiring` means, so an instruction that +/// ends it early empties the state. +#[tokio::test] +async fn test_a_retiring_feed_cannot_be_deleted_before_its_notice_ends() { + let (mut banks_client, program_id, payer, globalstate, feed) = catalog_feed("nodelete").await; + + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::RetireFeed(FeedRetireArgs {}), + feed_accounts(feed, globalstate), + &payer, + ) + .await; + + let result = try_execute_and_get_error( + &mut banks_client, + program_id, + DoubleZeroInstruction::DeleteFeed(FeedDeleteArgs {}), + feed_accounts(feed, globalstate), + &payer, + &[], + ) + .await; + assert_custom_at_ix0( + &result, + custom_code(DoubleZeroError::RetiringFeedCannotBeDeleted), + ); + assert_eq!( + feed_status(&mut banks_client, feed).await, + FeedStatus::Retiring, + "the refused delete leaves the feed as it was" + ); +} + +/// A retired feed deletes like any other catalog entry. The guard above holds during the notice, +/// not after it, or a finished retirement would leave an account nobody can clean up. +#[tokio::test] +async fn test_a_retired_feed_deletes() { + let program_id = Pubkey::new_unique(); + // Seeded past the notice rather than waiting thirty days for a catalog feed's. What `retire` + // writes is tested above; this is about what `delete` does once the state is `Retired`. + let (feed, data) = retiring_feed( + program_id, + "cleanup", + Pubkey::new_unique(), + Pubkey::default(), + 0, + ); + let (mut banks_client, payer, recent_blockhash) = + init_test_with_accounts(program_id, &[(feed, 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::FinalizeFeedRetirement(FeedFinalizeRetirementArgs {}), + vec![AccountMeta::new(feed, false)], + &payer, + ) + .await; + assert_eq!( + feed_status(&mut banks_client, feed).await, + FeedStatus::Retired + ); + + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::DeleteFeed(FeedDeleteArgs {}), + feed_accounts(feed, globalstate), + &payer, + ) + .await; + assert_eq!(get_account_data(&mut banks_client, feed).await, None); +}