Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Comment thread
bgm-malbeclabs marked this conversation as resolved.
Outdated
- `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.
Expand Down
5 changes: 5 additions & 0 deletions sdk/serviceability/python/serviceability/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Binary file modified sdk/serviceability/testdata/fixtures/feed.bin
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
bgm-malbeclabs marked this conversation as resolved.
Outdated
};

let data = borsh::to_vec(&val).unwrap();
Expand Down
7 changes: 7 additions & 0 deletions sdk/serviceability/typescript/serviceability/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1267,13 +1267,17 @@ 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.
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);
Expand All @@ -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,
Expand All @@ -1313,5 +1319,6 @@ export function deserializeFeed(data: Uint8Array): Feed {
committedRateBitsPerSec,
status,
haltedBy,
retiresAt,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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)?
}
Expand Down
14 changes: 13 additions & 1 deletion smartcontract/programs/doublezero-serviceability/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<DoubleZeroError> for ProgramError {
Expand Down Expand Up @@ -392,6 +398,9 @@ impl From<DoubleZeroError> 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),
}
}
}
Expand Down Expand Up @@ -524,6 +533,9 @@ impl From<u32> for DoubleZeroError {
123 => DoubleZeroError::FeedNotActive,
124 => DoubleZeroError::FeedNotHaltable,
125 => DoubleZeroError::FeedNotResumable,
126 => DoubleZeroError::FeedNotRetirable,
127 => DoubleZeroError::FeedNotRetiring,
128 => DoubleZeroError::RetirementNoticeNotElapsed,
_ => DoubleZeroError::Custom(e),
}
}
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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
Comment thread
bgm-malbeclabs marked this conversation as resolved.
}

impl DoubleZeroInstruction {
Expand Down Expand Up @@ -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),
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ pub fn process_create_feed(
FeedStatus::Pending
},
halted_by: Pubkey::default(),
retires_at: 0,
};

try_acc_create(
Expand Down
Original file line number Diff line number Diff line change
@@ -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(())
}
Original file line number Diff line number Diff line change
@@ -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::{
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Comment thread
bgm-malbeclabs marked this conversation as resolved.
&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;
Comment thread
bgm-malbeclabs marked this conversation as resolved.
Comment thread
bgm-malbeclabs marked this conversation as resolved.

try_acc_write(&feed, feed_account, payer_account, accounts)?;

msg!(
"Retiring feed: {} notice ends at {}",
feed_account.key,
feed.retires_at
);

Ok(())
}
Loading
Loading