Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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. 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.
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
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 @@ -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,
},
Expand Down Expand Up @@ -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());
Comment thread
bgm-malbeclabs marked this conversation as resolved.
}

let current_users = prior_seats
.iter()
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
Loading
Loading