Skip to content

serviceability: halt and resume a feed - #4307

Open
bgm-malbeclabs wants to merge 2 commits into
mainfrom
feat/feed-halt-and-resume
Open

serviceability: halt and resume a feed#4307
bgm-malbeclabs wants to merge 2 commits into
mainfrom
feat/feed-halt-and-resume

Conversation

@bgm-malbeclabs

Copy link
Copy Markdown
Contributor

Implements D1. FeedStatus and the gate that reads it landed with the subscriber work, but nothing could move the status: a staked feed sat in Pending for life, and no feed had a way to stop publishing.

The transition table

From HaltFeed ResumeFeed
Pending refused, nothing is publishing yet refused
Active to Halted refused, already active
Halted refused, already halted to Active
Retired refused, terminal refused, terminal

Each refusal says which way it is wrong, FeedNotHaltable (124) or FeedNotResumable (125), rather than sharing one error. A caller that halts twice should learn it did not stop something a second time, not read a generic argument failure.

Pending to Active is deliberately absent. That step re-reads the stake mirror, which is the only thing that catches a mirror corrected after it admitted a feed, so it belongs with the code that does it (G1). Leaving it out here keeps the two apart rather than half-implementing it.

Who can sign

The feed's own builder may sign either instruction. No other feed instruction consults feed.builder at all; they authorize on FEED_AUTHORITY | FOUNDATION and stop there.

Both paths are needed, for different reasons:

  • The builder, because RFC-28 makes halt the builder's lever and doubles it as a way to rotate the upstream source without redeploying. A builder that cannot halt its own feed cannot rotate either.
  • A FEED_AUTHORITY or FOUNDATION key, because a feed whose builder has gone quiet must still be stoppable, and every other feed instruction already works that way.

A catalog feed has no builder, so only the second path applies to it. The check compares against the default pubkey explicitly rather than relying on nobody being able to sign as it.

Testing Verification

Five tests in a new feed_lifecycle_test.rs. A new file rather than helpers bolted onto feed_test.rs, because its init_staked hardcodes a builder the test cannot sign as, and other tests depend on that.

  • Halt then resume, asserting the status at each step and that the feed's name and groups are untouched. Halt is a status change, not an edit.
  • Halting a halted feed is refused, so a no-op cannot be mistaken for having stopped something.
  • Resuming an active feed is refused, for the same reason.
  • A Pending feed neither halts nor resumes. This also guards G1's territory: nothing here moves a feed out of Pending.
  • The builder is authorized on its own feed, and a stranger is not. Both calls fail, because no Pending feed can be halted by anyone and G1 does not exist yet to make a staked feed Active. The errors are what distinguish them: the builder reaches the status check and gets FeedNotHaltable, the stranger does not get past authorization and gets NotAllowed.

That last one is the honest shape of the test available today. A staked feed cannot reach Active until G1, so the builder's successful halt cannot be tested yet, and asserting on which check stopped the call is the strongest available evidence that the authorization path works.

Full suite passes: 327 serviceability tests plus the 5 new ones, 86 in the instruction crate, clippy --all-targets -Dwarnings and fmt --check clean.

One thing the compiler could not catch, worth knowing for the next variant: DoubleZeroInstruction::unpack matches the leading byte by hand with a catch-all, and From<ProgramError> for DoubleZeroError matches the code the same way. Both compile fine with a variant missing. The first showed up as InvalidInstructionData at runtime and the second as a round-trip test failure.

`FeedStatus` and the gate that reads it landed with the subscriber work, but
nothing could move the status. A staked feed sat in `Pending` for life and no
feed had a way to stop publishing.

Two instructions and the transitions they refuse. Halt goes only from
`Active`, resume only from `Halted`, and each refusal says which way it is
wrong rather than sharing one error. `Pending` to `Active` is deliberately
absent: that step re-reads the stake mirror, so it belongs with the code that
does, and leaving it out here keeps the two apart.

The feed's own builder may sign either one, which no other feed instruction
allows. RFC-28 makes halt the builder's lever and doubles it as a way to
rotate the upstream source without redeploying, so a builder that cannot halt
its own feed cannot do either. A `FEED_AUTHORITY` or `FOUNDATION` key may
sign as well, because a feed whose builder has gone quiet must still be
stoppable.
@bgm-malbeclabs
bgm-malbeclabs requested review from a team and a lite review from Copilot September 9, 2026 23:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The on-chain lifecycle transitions, authorization rules, instruction wiring, error mappings, and new integration tests are consistent and complete for the stated D1 scope.

Pull request overview

This pull request adds feed lifecycle controls to the DoubleZero serviceability on-chain program so an authorized signer can halt an Active feed and resume a Halted feed, unblocking status transitions now that FeedStatus gating exists.

Changes:

  • Add HaltFeed (variant 120) and ResumeFeed (variant 121) instruction variants, entrypoint routing, and processors that enforce the transition table via FeedNotHaltable (124) and FeedNotResumable (125).
  • Add a shared require_feed_writer authorization helper that allows either the feed builder or a FEED_AUTHORITY | FOUNDATION signer to perform lifecycle changes.
  • Add a new SVM test suite covering allowed transitions and refusal cases, including authorization-path differentiation.
File summaries
File Description
smartcontract/programs/doublezero-serviceability/src/processors/feed/halt.rs New processor to transition Active -> Halted with explicit refusal error otherwise.
smartcontract/programs/doublezero-serviceability/src/processors/feed/resume.rs New processor to transition Halted -> Active with explicit refusal error otherwise.
smartcontract/programs/doublezero-serviceability/src/processors/feed/mod.rs Expose halt/resume modules and add require_feed_writer to support builder-or-permission authorization.
smartcontract/programs/doublezero-serviceability/src/instructions.rs Add instruction variants 120/121 plus unpack/name/debug wiring.
smartcontract/programs/doublezero-serviceability/src/entrypoint.rs Route HaltFeed / ResumeFeed to the new processors.
smartcontract/programs/doublezero-serviceability/src/error.rs Add FeedNotHaltable/FeedNotResumable and map them to custom codes 124/125.
smartcontract/programs/doublezero-serviceability/tests/feed_lifecycle_test.rs New integration tests for lifecycle transitions and refusal/authorization behavior.
crates/doublezero-serviceability-instruction/src/feed.rs Add instruction builders for halt_feed and resume_feed.
CHANGELOG.md Document the new instructions, transition constraints, and signer rules.
Review details
  • Files reviewed: 9/9 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@bgm-malbeclabs
bgm-malbeclabs enabled auto-merge (squash) September 10, 2026 00:30

@armcconnell armcconnell left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked against edge-feed-spec GLOSSARY.md (a5f5cdf): conforms.
See inline comments.


let mut feed = Feed::try_from(feed_account)?;
let globalstate = GlobalState::try_from(globalstate_account)?;
require_feed_writer(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Important] Resume is gated on the same key set as halt, so a builder can immediately undo a FEED_AUTHORITY/FOUNDATION halt; with Retired unreachable and DeleteFeed refusing staked feeds, nothing then stops a live staked feed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, and this was a hole rather than a nit. Fixed in d0a6b09c9.

I had given the builder co-equal power on resume, which meant an operator's halt bought nothing: the builder reverses it in the next transaction. And as you say, there is no second lever. Retired is unreachable until D2, and DeleteFeed refuses a staked feed. So the halt was the only way to stop a staked feed, and it did not stop one.

Feed now carries halted_by, set to the signer that halted:

  • Halted by anyone other than the feed's builder: only a FEED_AUTHORITY or FOUNDATION key can resume.
  • Halted by the builder: either the builder or an operator can resume, which keeps the source-rotation workflow the instruction exists for.

A pubkey rather than a flag, so an operator can see who did it, and so D2 has it. It appends to the account and reads as zero on a feed written before the field, which is right: such a feed cannot have been halted.

Two tests, and neither passes without the change: a builder is refused with NotAllowed on an operator's halt and the status stays Halted, and the builder lifts its own halt successfully.

/// `builder` may sign this one, so a builder can rotate its upstream source without an operator.
/// A `FEED_AUTHORITY` or `FOUNDATION` key can sign it too, which is why this stays on the
/// permission-appending path.
pub fn halt_feed(program_id: &Pubkey, payer: &Pubkey, feed: &Pubkey) -> Instruction {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] halt_feed and resume_feed have no test; test_feed_pubkey_verbs pins the tag byte and account metas for update and delete but not for 120/121.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d0a6b09c9. test_feed_pubkey_verbs now pins 120 and 121 alongside 113 and 114, tag byte and account metas both.

Worth saying why that test earns its keep here specifically: DoubleZeroInstruction::unpack matches the leading byte by hand and ends in a catch-all, so a wrong or missing tag compiles cleanly and arrives at the program as InvalidInstructionData. That is exactly how it failed the first time I ran these tests, and the tag assertion is what would have caught it at the crate boundary instead.

return Err(DoubleZeroError::FeedNotResumable.into());
}

feed.status = FeedStatus::Active;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] Resume re-enters Active without re-reading the StakeMirror — the check Pending to Active is deferred to G1 for. Once G1 lands, a staked feed can halt, have its mirror corrected downward, and resume uncovered.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken, and closed now rather than left for G1. Fixed in d0a6b09c9.

Resume re-reads the mirror and re-proves the cover, through a new require_stake_still_covers. It is not verify_stake_covers_rate: creation claims an unspent stake and so requires feed_key to be empty, while here the feed already holds the claim, so the mirror must name this feed and no other. What both check is the tier.

A staked feed that omits its mirror account is refused with StakeMirrorMissing rather than read as having no stake to check. That was the shape of the original bug, so it gets its own test.

I did not defer this. You are right that it is unreachable today, since a staked feed cannot reach Active and therefore cannot be halted. But G1 is sprint 3, and a known hole that waits for the feature that opens it is how holes ship. Closing it now costs one helper and two tests.

The test corrects the mirror down to StakeTier::None while the feed sits halted, then asserts resume is refused with StakeDoesNotCoverRate.

Three review comments, and the first one is a hole.

Resume took the same signers as halt, so a builder undid an operator's halt
the moment it landed. `Retired` is unreachable until D2 and `DeleteFeed`
refuses a staked feed, so that halt is the only lever an operator has, and a
builder that can reverse it leaves no lever at all. `Feed` now records
`halted_by`, and a halt by anyone other than the builder takes an operator to
lift. A builder's own halt is still the builder's to lift, which is the
source rotation the whole instruction exists for.

Resume also re-proves the stake. A mirror can be corrected downward while a
feed sits halted, so resuming on the check made at creation would let a feed
publish at a rate its stake no longer backs. A staked feed that omits its
mirror is refused rather than read as having no stake to check.

The builders had no test. They now pin the tag byte and the account metas,
which is what catches a wrong variant: `unpack` matches the leading byte by
hand with a catch-all, so the compiler never sees it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants