Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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), 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.
Expand Down
57 changes: 55 additions & 2 deletions crates/doublezero-serviceability-instruction/src/feed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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::*;
Expand Down Expand Up @@ -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();
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
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
Exchange,
FEED_STATUS_ACTIVE,
FEED_STATUS_PENDING,
FEED_STATUS_RETIRING,
Feed,
GlobalConfig,
GlobalState,
Expand Down Expand Up @@ -627,14 +628,19 @@ 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
assert feed.bump_seed == 239
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
Expand Down
Binary file modified sdk/serviceability/testdata/fixtures/feed.bin
Binary file not shown.
12 changes: 11 additions & 1 deletion sdk/serviceability/testdata/fixtures/feed.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,18 @@
},
{
"name": "Status",
"value": "0",
"value": "4",
"typ": "u8"
},
{
"name": "HaltedBy",
"value": "11111111111111111111111111111111",
"typ": "pubkey"
},
{
"name": "RetiresAt",
"value": "-1764547200",
"typ": "i64"
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -1518,8 +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(),
// 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();
Expand All @@ -1542,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() },
],
};

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 @@ -22,6 +22,7 @@ import {
deserializeFeed,
FEED_STATUS_ACTIVE,
FEED_STATUS_PENDING,
FEED_STATUS_RETIRING,
} from "../state.js";

const FIXTURES_DIR = join(
Expand Down Expand Up @@ -647,14 +648,19 @@ 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);
expect(feed.bumpSeed).toBe(239);
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
Expand Down
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
18 changes: 17 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,14 @@ 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
#[error("A retiring feed must finish its notice before it can be deleted")]
RetiringFeedCannotBeDeleted, // variant 129
}

impl From<DoubleZeroError> for ProgramError {
Expand Down Expand Up @@ -392,6 +400,10 @@ 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),
DoubleZeroError::RetiringFeedCannotBeDeleted => ProgramError::Custom(129),
}
}
}
Expand Down Expand Up @@ -524,6 +536,10 @@ impl From<u32> for DoubleZeroError {
123 => DoubleZeroError::FeedNotActive,
124 => DoubleZeroError::FeedNotHaltable,
125 => DoubleZeroError::FeedNotResumable,
126 => DoubleZeroError::FeedNotRetirable,
127 => DoubleZeroError::FeedNotRetiring,
128 => DoubleZeroError::RetirementNoticeNotElapsed,
129 => DoubleZeroError::RetiringFeedCannotBeDeleted,
_ => DoubleZeroError::Custom(e),
}
}
Expand Down Expand Up @@ -558,7 +574,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
Loading
Loading