StableGuard by FintechCheck: Real-Time Stablecoin Depeg Detection for Treasury & DeFi Protection

Hi all,

I’m Matthew, founder of FintechCheck, building real-time DeFi risk monitoring infrastructure. I wanted to introduce StableGuard, our onchain depeg protection system, and explore whether it could be useful to ArbitrumDAO’s treasury operations or wider ecosystem.

What StableGuard does:

  • Real-time monitoring across 19+ stablecoins with confidence-scored depeg detection (price feed + DEX divergence + market stress signals)
  • Backtested at 83.8% classification accuracy across 260,950+ data snapshots and 37 real historical depeg events
  • Live Chainlink integrations: Price Feeds, Automation, CCIP, with a CRE-based workflow currently in deployment for automated protective actions (e.g. vault-pausing on detected depeg risk)
  • Contracts live and tested on Sepolia; production deployment in progress

Why this could matter for Arbitrum:
Arbitrum’s treasury and DAO-affiliated protocols hold significant stablecoin reserves. An independent, real-time risk signal — not built by the stablecoin issuers themselves — offers a credibility advantage: early warning before a depeg event affects treasury value or dependent protocols. Given Arbitrum’s recent move to support agentic payment infrastructure (x402/MPP), this also aligns with the broader push toward automated, onchain financial tooling.

What I’m looking for:
Feedback from delegates and the Treasury Management Council on whether this is a gap worth addressing, and whether there’s an appropriate grant or procurement pathway (noting the DAO has previously run a data monitoring procurement process) to pilot this for Arbitrum-related treasuries or ecosystem protocols.

Happy to answer technical questions or share a live demo.

Thanks for reading — open to any feedback.

Matthew
FintechCheck / StableGuard

2 Likes

The problem is real, but the current public evidence does not yet support an automated treasury-protection claim.

I reviewed the published methodology and the current pegcheck / StableGuard implementation. The main issue is not the idea of depeg monitoring. It is that four different layers are currently being collapsed into one result:

market observation → event classification → confidence state → admissible protective action

Those layers need separate proof.

1. The reported 83.8% is not classification accuracy

The repository defines 37 events by applying the system’s own HEDGE threshold and grouping threshold crossings separated by more than three hours.

It then calls 31 of those 37 events “confirmed” because the signal persisted for at least two consecutive hourly readings.

Therefore:

31 / 37 = 83.8%

is a persistence or confirmation rate among system-generated threshold events.

It is not classification accuracy against an independent ground-truth event set.

The published material does not currently establish:

  • false negatives;
  • precision or recall;
  • specificity;
  • calibration of the confidence score;
  • out-of-sample performance;
  • detection lead time before a material depeg;
  • false protective-action rate;
  • cost-weighted consequences of pausing when the signal is wrong.

The 260,950 snapshots describe the observation corpus. They do not become labelled classification examples merely because a threshold was applied to them.

The claim should therefore be renamed and bounded exactly. A proper protection backtest requires a preregistered event definition, independent event labels, forward-time validation, per-asset results, lead-time distribution, and explicit false-action costs.

2. The 19-asset monitor and the on-chain actuator are different systems

The off-chain monitor covers 19 stablecoins on an hourly schedule and aggregates multiple web/API sources.

The current on-chain StableGuard.sol path evaluates only USDC and DAI through Chainlink feeds, with one optional Uniswap V3 pool.

The off-chain confirmation rate cannot be used as validation of the on-chain action contract unless the exact same:

  • assets;
  • sources;
  • sampling frequency;
  • thresholds;
  • persistence rules;
  • freshness rules;
  • execution semantics

are bound into one reproducible evaluation.

At present, the monitoring surface and the autonomous action surface have different evidence contracts.

3. The protection threshold contradicts the confidence description

In StableGuard.sol, a Chainlink depeg alone produces score = 3.

The source contract sends a CCIP alert at score >= 3.

The destination StableGuardReceiver then calls vault.pause() at score >= 3.

That means the automated pause path is activated by one Chainlink feed, not by a two-source high-confidence state.

The Uniswap confirmation raises the score to 5, but score 5 is not required for the destination action.

The code comments describe score 5 as the full-protection response, while the receiver performs the protection at score 3. The action boundary and the public confidence narrative therefore do not match.

A defensible separation would be:

  • one fresh source: observation or warning;
  • two genuinely independent, asset-specific sources: confirmed depeg;
  • persistence and liquidity checks: protection admissible;
  • destination policy: exact action authorised.

4. The DAI cross-check is not bound to DAI

The contract stores one uniswapPool, documented as a USDC/USDT pool.

That same pool is used to add the +2 DEX confirmation both when USDC is depegged and when DAI is depegged.

A USDC/USDT divergence is not independent confirmation of a DAI/USD depeg.

Even for USDC, a USDC/USDT pair divergence proves only that the pair moved. It does not identify which side caused the divergence without an external reference.

For DAI, the source is semantically unrelated.

Every confidence contribution must be bound to:

asset → quote asset → venue → pool identity → liquidity → observation window → direction

and a DEX confirmation should use an asset-relevant TWAP with a liquidity floor, not an instantaneous slot0 reading.

5. “Real-time” freshness and cross-chain execution need a state machine

The current feed freshness boundary accepts Chainlink data up to 24 hours old.

That is incompatible with an autonomous real-time pause path. Freshness should be feed-specific and action-specific.

The source contract also permits a new broadcast every five minutes while the condition remains true. performUpkeep is publicly callable and recomputes the signal, so any caller can repeatedly advance the fee-spending CCIP path during a sustained depeg after each cooldown.

The destination side has no explicit:

  • transition identifier;
  • processed-message ledger;
  • evidence expiry;
  • alert supersession;
  • recovery state;
  • retry state;
  • all-destination reconciliation;
  • asset-to-vault exposure binding.

The receiver pauses any configured vault for any accepted symbol at score 3. It does not establish that the vault is exposed to the alerted asset.

CCIP delivery failures are handled destination by destination. Some destinations can therefore receive and act while others fail, leaving the system in a partially protected cross-chain state without a reconciliation contract.

The correct object is not a repeated alert. It is an idempotent state transition:

NORMAL → WATCH → CONFIRMED_DEPEG → PROTECTION_PENDING → PROTECTED → RECOVERY_PENDING → NORMAL

Each transition should carry:

  • stable event ID;
  • asset and exposure scope;
  • source observations and timestamps;
  • evidence root;
  • confidence components;
  • threshold version;
  • source-chain block and finality state;
  • destination action policy;
  • expiry;
  • supersession/recovery rule;
  • per-destination completion state.

6. What should be demonstrated before an Arbitrum treasury pilot

A credible pilot should publish two separate records.

Detection record

  • independent historical event baseline;
  • forward-time train/test separation;
  • per-stablecoin precision, recall and false-negative count;
  • false alarms per observation-day;
  • median and worst detection lead time;
  • score calibration;
  • source-ablation results;
  • results under stale, unavailable and conflicting sources;
  • DEX manipulation and low-liquidity tests.

Action record

  • exact evidence state required for alert, hedge, pause and recovery;
  • asset-to-vault exposure binding;
  • idempotent cross-chain transition ID;
  • destination acknowledgement and retry ledger;
  • partial-delivery handling;
  • stale-message rejection;
  • maximum action latency;
  • human/governance override;
  • recovery and unpause policy;
  • invariant tests proving that one source, one pool, or one delayed message cannot silently cause an unintended treasury action.

StableGuard is aimed at a real problem, and the public implementation is far enough along to make the missing boundary visible.

But the key requirement is this:

A depeg signal is not yet a protection decision. The system must prove which evidence state authorises which action, for which asset exposure, for how long, and with what recovery path.

Once that boundary is explicit, the project can be evaluated as treasury protection infrastructure rather than as a monitoring dashboard connected to an actuator.

Hi,

Thanks for taking the time to write this up, genuinely useful and you’re right on the core point.

The current receiver pauses on accepted symbol at score 3, it doesn’t verify the vault actually holds the alerted asset. That’s a real gap, not a nuance, since a symbol match isn’t the same as a proven exposure. Same with CCIP: I’ve been treating delivery as effectively atomic across destinations, which it isn’t, and there’s no reconciliation contract handling partial delivery today.

The state machine you laid out (NORMAL through RECOVERY_PENDING with a stable event ID and per-destination completion state) is a clean way to make the implicit alert loop explicit and idempotent. I’m going to build that as the next milestone rather than iterating on the alert logic further.

Same for the two-record structure. Right now I have a demo and a single fork test proving one path works, not a detection record (precision/recall, false-negative count, lead time, ablation results) or an action record (exposure binding, retry ledger, stale-message rejection, override/recovery policy). That’s the actual bar for treasury-protection infrastructure vs a monitoring dashboard connected to an actuator, and I don’t think StableGuard clears it yet.

I’m going to work through this as a build list over the next couple of weeks; asset-to-vault exposure binding and the idempotent transition object first, since everything else depends on those. Happy to share progress as it lands, and if you’re willing, I’d value a second pass once the exposure binding and transition object are in place.

Thanks again, this was exactly the kind of feedback I needed.

Cheera

Matthew

That sequencing is correct.

Exposure binding and the idempotent transition object are the right first milestone. Before extending the alert logic further, I would lock three invariants into the implementation and tests:

  1. An alert for an asset the vault does not actually hold must be incapable of changing vault state.
  2. Duplicate, stale, replayed or out-of-order CCIP messages must never produce an illegal transition.
  3. Partial delivery across destinations must always reconcile back to one canonical event state, with no ambiguity about which destinations are complete, pending, superseded or failed.

Keep the detection record and the action record separate while doing this. A detection can be valid while the resulting treasury action is still inadmissible.

Once the exposure binding and transition object are in place, send the implementation or diff. I’ll review the transition semantics, replay/reconciliation boundaries and failure paths rather than just the happy-path flow.

Hi,

Agreed on all three, and I’ll treat them as the acceptance criteria for this milestone rather than nice-to-haves:

  1. Non-exposed asset cannot change vault state — enforced at the exposure binding layer, with the negative test (alert for asset A, vault holds only B, vault state must not change) as a required regression test, not just a manual check.
  2. Duplicate/stale/replayed/out-of-order CCIP messages cannot produce an illegal transition — this pushes me toward making the event ID + sequencing part of the transition guard itself, not just a filter before it, so an out-of-order message is rejected by the state machine’s own transition rules rather than by something upstream that could be bypassed.
  3. Partial delivery always reconciles to one canonical event state, with explicit per-destination status (complete/pending/superseded/failed) and no ambiguous in-between.

And yes, keeping detection and action strictly separate. A valid depeg detection doesn’t imply an admissible action, that boundary is exactly what was missing before, so I’m not going to blur it back together for convenience.

I’ll send the implementation/diff once exposure binding and the transition object are in, with the invariant tests included, not just the happy path. Given what you’re planning to review, I’ll make sure the replay/reconciliation and failure-path tests are easy to find and run in isolation.

Thanks, this is a genuinely useful bar to build against.

Cheers

Matthew

Stablecoin depeg monitoring is a relevant need for Arbitrum treasury and ecosystem protocols. However, monitoring signals should not directly become automated treasury actions without independent validation, asset specific evidence, strict freshness rules, human override, and a clear recovery process. A limited read only pilot with transparent performance reporting may be a safer first step than direct vault pause authority. @Matthew77

Thanks, that’s a fair and honestly a safer path than what I originally proposed.

I agree a read-only pilot is the right first step. Concretely, that would mean: StableGuard publishes depeg signals and its full evidence trail (source observations, confidence components, score) on-chain or via a public feed, but no vault ever receives pause authority during this phase. Treasury/protocol teams could consume the signal and decide manually whether to act, with StableGuard reporting precision, recall, false-alarm rate, and detection lead time openly the whole time.

That also lines up with feedback I’ve had elsewhere on this thread, separating detection from action, since a valid detection shouldn’t imply an admissible automated action. A read-only pilot forces that separation by construction rather than by policy, which is a stronger guarantee.

If/when there’s a case for moving beyond read-only, I think the bar should be: independent validation of the signal, asset-specific evidence (not just symbol matching), strict message freshness rules, human override on any action, and a defined recovery/unpause process, before any automated vault authority is even discussed.

Happy to scope a read-only pilot proposal along those lines if that’s useful.

Update since my last reply, in case useful — the pieces I said I’d build toward that read-only pilot are now actually built and public, not just planned:

Asset-specific evidence — exposure binding verifies a vault actually holds the affected asset before any action is even considered; a depeg alert for an asset a vault doesn’t hold can’t trigger anything, and this is unit-tested.

Freshness / replay safety — the transition state machine rejects stale or replayed messages once a destination has settled, preventing duplicate or out-of-order actions.

Detection Record, published — 49 objectively-labeled historical depeg events (including UST/LUNA and the SVB/USDC event), scored against the actual detection logic: 100% recall, 0 false negatives, with precision explained honestly (early-warning signals are supposed to fire on ordinary noise, that’s not a flaw). Full methodology and labeling rules are documented, not just the headline numbers.

Action Record, published — documents exactly what evidence is required before each action (alert/pause/recovery), citing the specific tests that prove it, and explicitly lists what’s not yet proven (still pending real cross-chain CCIP conditions) rather than glossing over gaps.

Recovery process — this was the one open question even I flagged as unresolved. It’s now fully automatic: a vault only unpauses after sustained confirmation the price has genuinely stabilized (not a single data point), with no human step required, and no reliance on someone remembering to trigger recovery manually. Also closed a real edge case where a slow-recovering incident could otherwise leave a vault stuck paused indefinitely with nothing tracking it.

All of this is sitting in open PRs with an external reviewer right now (unrelated to Arbitrum — a security-minded community contact), so it’s genuinely being scrutinized, not just self-reported.

Given where things stand, I think the read-only pilot proposal I mentioned is worth actually writing up properly now, backed by this instead of intentions. Happy to put that together if there’s continued interest — would rather have something concrete to react to than keep describing plans.

1 Like

Thanks for laying out those three invariants clearly — all three are now built and tested, matching your framing:

  1. Exposure binding: an alert for an asset the vault doesn’t hold can’t change vault state — unit tested.
  2. Duplicate/stale/replayed/out-of-order messages can’t produce an illegal transition — the transition object rejects writes to any destination once it’s left PENDING.
  3. Partial delivery reconciles to one canonical event state — lookup-before-create keeps one active event per coin, with explicit COMPLETE/PENDING/SUPERSEDED/FAILED states, no ambiguity.

Detection record and action record are kept separate, as you suggested — published separately, with the action record explicitly noting which parts are still unproven pending real CCIP conditions.

My account isn’t letting me post a link here (getting a ‘can’t post a link to a host’ error) — the implementation is on GitHub, search for the user matsblocknode1957 hyphen oss, repo name depegguard hyphen skill, pull request number 1. Happy to send the actual link another way if that’s easier — just let me know.

Apologies if this should have come to you directly sooner — I think it went to a different contact by email rather than here. Happy to answer anything on the transition semantics, replay/reconciliation boundaries, or failure paths whenever you get a chance to look.

DepegGuard / StableGuard — Second-Pass Technical Review

Matthew, this will be a long description because the issue is not a single defect; it affects the structure of the code as a whole.

Appreciate for the implementation update and for publishing the contracts, workflow, tests, and ACTION_RECORD.md openly. The first three invariants are no longer merely described; they are represented in code and tests:

  1. an alert for an unregistered exposure cannot enter the pause path;
  2. destination slots reject writes after they leave PENDING;
  3. lookup-before-create preserves one active event per coin and makes terminal closure explicit.

The separation between the detection record and the action record is also the correct direction, and the explicit list of conditions that remain unproven is materially better than presenting local tests as production evidence.

This second pass is therefore not a rejection of that work. It is the next layer exposed by the fact that the original layer was implemented successfully. The remaining problems now sit mainly between individually reasonable state machines:

observation state
        ≠
asset-incident state
        ≠
physical vault state
        ≠
delivery-attempt state

The current branch still collapses some of those identities into one another. That produces several reachable cases in which the event ledger and the physical system can disagree.

Review target:

  • implementation branch: feat/automatic-recovery
  • implementation commit: 8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf
  • documentation commit: 999baaa4bcc47f25c5df1475bf4c75228083d819

The reviewed implementation reports 85/85 tests passing. The analysis below is state-space and code-path review: it focuses on cross-object combinations that the current tests do not represent, so the existing pass count does not close these findings.


Executive conclusion

I would not connect the current receiver to a real multi-asset vault or real asynchronous delivery path yet.

Five items are production blockers:

  1. independent asset incidents can independently unpause one shared vault;
  2. resumeProtectionTracking() can manufacture an incident for the wrong asset from the single fact that the vault is paused;
  3. report acceptance authenticates the Forwarder but not the intended workflow identity;
  4. report replay and duplicate asset entries can satisfy stabilityWindow without new observations;
  5. transferring the registry controller to the receiver makes several documented control and recovery operations unreachable.

The remaining findings concern attempt identity, partial-delivery truth, reconciliation, TTL determinism, evidence lineage, asset identity, and configuration hardening. They are all repairable without discarding the work already completed.


I. Blocking invariants

B-01 — Per-asset incidents control one global vault actuator

Severity: Blocker
Status: Confirmed from the current control flow

StableGuardCREReceiver has one immutable vault, but it loops over an array of coins and advances a separate active event for each coin. When any one coin reaches RECOVERY_PENDING, the receiver calls vault.unpause() directly. There is no check for another active protected incident attached to the same vault.

Relevant code:

Reachable trace

USDC incident = PROTECTED
USDT incident = PROTECTED
vault.paused() = true

USDC then produces stabilityWindow accepted stable calls
USDC incident -> RECOVERY_PENDING
receiver processes USDC
receiver calls vault.unpause()
USDC destination -> COMPLETE
USDC may eventually -> NORMAL

USDT incident is still PROTECTED
but vault.paused() = false

The USDC event has authority over a physical actuator that is also enforcing the USDT event. The logical scope is per asset; the actuator scope is per vault. Those scopes are not equivalent.

This is not solved by making pause() and unpause() idempotent. Idempotence prevents a revert; it does not preserve the conjunction of active protection requirements.

Required invariant

For every vault V:

V may be unpaused
iff
there is no active protection hold requiring V to remain paused.

Formally:

unpauseAllowed(V) <=> activeHoldCount(V) == 0

Recommended repair

Introduce a vault-level protection coordinator or equivalent hold ledger:

struct ProtectionHold {
    bytes32 holdId;
    bytes32 rootIncidentId;
    bytes32 assetId;
    address vault;
    bool active;
}

mapping(address vault => uint256 count) public activeHoldCount;
mapping(bytes32 holdId => ProtectionHold) public holds;

Protection becomes:

acquire hold for (vault, asset, rootIncident)
if activeHoldCount changed 0 -> 1:
    physically pause vault

Recovery becomes:

release only this incident's hold
if activeHoldCount changed 1 -> 0:
    physically unpause vault
else:
    keep vault paused

The physical action must be derived from the aggregate hold set, not from the state of one coin event.

Minimal alternative

If a vault is intentionally single-asset, make that restriction structural:

one receiver instance
+ one vault
+ one canonical assetId
+ no multi-coin action loop

That is a valid smaller design. What is unsafe is advertising a multi-coin receiver while retaining a one-bit actuator with no aggregate authority model.

Missing test

1. Register USDC and USDT exposure for the same vault.
2. Drive both incidents to PROTECTED.
3. Feed stabilityWindow stable reports for USDC only.
4. Continue feeding HEDGE for USDT.
5. Assert USDC can close its own hold.
6. Assert vault remains paused while the USDT hold remains active.

B-02 — resumeProtectionTracking() reconstructs causality from the wrong fact

Severity: Blocker
Status: Confirmed; batch order changes the result

The continuation branch is evaluated when:

eventId == bytes32(0) && vault.paused()

It then creates a fresh PROTECTED event for the coin currently being processed. This happens before the exposure gate, and it does not require:

  • a prior event for that coin;
  • a prior event that expired;
  • a parent event ID;
  • an active hold belonging to that coin;
  • evidence that this coin caused the pause;
  • evidence that the existing pause belongs to DepegGuard at all.

Relevant code:

Single-report reproduction

Take one report with ordered arrays:

coins        = [USDC, USDT]
signalLevels = [HEDGE, STABLE]

Assume both coins have no active event and USDC is registered as an exposure.

The receiver loop executes sequentially:

USDC:
    processReport -> CONFIRMED_DEPEG
    initiateProtection
    vault.pause()
    USDC -> PROTECTED

USDT:
    processReport(STABLE) -> (0, NORMAL)
    eventId == 0
    vault.paused() == true
    resumeProtectionTracking(USDT, ...)
    USDT -> PROTECTED

The system has now created a protected USDT incident even though the same report said USDT was stable and no prior USDT incident existed.

Worse, reversing the array order changes the result:

[USDT STABLE, USDC HEDGE]

USDT is processed before the vault is paused, so no USDT event is created. The final state therefore depends on array order, not only on the observations.

That violates permutation invariance for a report whose coin observations are logically independent.

Consequence

The fabricated USDT event can later accumulate stable calls, enter RECOVERY_PENDING, and invoke vault.unpause() while the real USDC depeg remains active. In combination with B-01, this creates a complete false-unpause path.

Required invariant

A continuation event may exist only if it inherits a verified, still-active
protection hold for the same (vault, assetId, rootIncidentId).

A global physical bit such as paused() can confirm physical state, but it cannot identify the cause of that state.

Recommended repair

Do not infer incident lineage from vault.paused().

Either remove resumeProtectionTracking() entirely by separating hold lifetime from event-epoch lifetime, or require explicit lineage:

function resumeProtectionTracking(
    bytes32 parentEventId,
    bytes32 rootIncidentId,
    bytes32 holdId,
    bytes32 assetId,
    bytes32 observationId
) external returns (bytes32 newEventId);

The registry must verify:

parentEvent is terminal for an allowed continuation reason
parentEvent.assetId == assetId
hold[holdId].active == true
hold[holdId].assetId == assetId
hold[holdId].rootIncidentId == rootIncidentId
hold[holdId].vault == destination vault

A new event epoch may be created, but it must not create a new causal history.

Missing tests

  1. [A=HEDGE, B=STABLE] must not create a B incident.
  2. Reversing the order to [B=STABLE, A=HEDGE] must produce the same logical state.
  3. A vault paused for an unrelated reason must not permit any continuation event.
  4. An expired A event must not authorize continuation for B.
  5. A continuation must fail if the corresponding hold was already released.

B-03 — The receiver authenticates a Forwarder, not the intended workflow

Severity: Blocker before state-changing production use
Status: Confirmed security-boundary gap

The receiver checks only:

if (msg.sender != forwarder) revert UnauthorizedForwarder(msg.sender);

The metadata argument is explicitly ignored. Therefore, the consumer does not distinguish the intended DepegGuard workflow from another valid workflow delivered through the same Chainlink Forwarder.

Relevant code:

The Forwarder check proves the delivery channel. It does not, by itself, prove the application-level author of the report.

Required invariant

A state-changing report is accepted only when:

caller == configured Forwarder
AND workflowId == expectedWorkflowId
AND workflowOwner == expectedWorkflowOwner
AND destination chain == configured chain
AND receiver == address(this)

Workflow name may be checked as an additional label, but it should not be used without owner validation; the official template makes that distinction explicitly.

Recommended repair

Inherit from or reproduce the relevant checks from ReceiverTemplate and configure at least:

expected Forwarder
expected workflow ID
expected workflow owner/author

Also ensure that chain selector and receiver identity are authenticated and bound before any state mutation, whether through authenticated CRE metadata, a signed report envelope, or an equivalent domain-separated mechanism, so a report cannot be validly reused in another domain.

Missing tests

  • correct Forwarder + wrong workflow ID → revert;
  • correct Forwarder + wrong workflow owner → revert;
  • correct workflow + wrong receiver domain → revert;
  • correct workflow + wrong chain selector → revert;
  • correct metadata and report → accepted.

B-04 — stabilityWindow counts calls, not fresh observations

Severity: Blocker
Status: Confirmed

While an event is PROTECTED, each call to processReport() with a score below watchThreshold increments stableCount. The registry receives no observation sequence, source timestamp, report ID, or digest that must be unique. The receiver also does not reject the same coin appearing more than once in a report array.

Relevant code:

Chainlink’s own documentation states that signed reports can be replayed on another chain or resubmitted on the same chain and that state-changing consumers must embed and verify protective metadata.

Two direct failure modes

A. Report replay

With stabilityWindow = 3:

one genuine stable observation
same signed report delivered three times
stableCount: 0 -> 1 -> 2 -> recovery

The system interprets repeated delivery of one observation as three consecutive observations.

B. Duplicate coin entries inside one accepted report

coins        = [USDC, USDC, USDC]
signalLevels = [STABLE, STABLE, STABLE]

The receiver loops three times and calls processReport() three times in one transaction. The same payload can satisfy the entire stability window immediately.

Required invariant

stableCount may advance at most once per asset per accepted observation sequence.

A stronger definition is:

accepted observation n+1 must have:
sequence > lastAcceptedSequence
sourceObservedAt > lastAcceptedSourceObservedAt
sourceObservedAt within an allowed freshness window
assetId unique within the report

Recommended report envelope

struct ReportEnvelope {
    bytes32 workflowId;
    uint64 sourceChainSelector;
    address receiver;
    uint64 sequence;
    uint64 sourceObservedAt;
    uint64 validUntil;
    bytes32 payloadDigest;
    CoinObservation[] observations;
}

struct CoinObservation {
    bytes32 assetId;
    bytes32 feedId;
    int192 rawPrice;
    bytes32 evidenceDigest;
}

The receiver should reject:

sequence <= lastAcceptedSequence[workflowId]
sourceObservedAt <= lastObservedAt[assetId]
block.timestamp > validUntil
sourceObservedAt too far in the future
sourceObservedAt older than maxReportAge
repeated assetId within one envelope
wrong chain selector
wrong receiver
wrong workflow identity

Only after those checks should the registry receive an accepted observation ID and update stableCount.

Missing tests

  • replay the exact same stable report stabilityWindow times; count must advance once;
  • submit the same sequence with modified payload; reject;
  • submit a lower sequence; reject;
  • submit a stale timestamp; reject;
  • include the same asset twice in one report; reject atomically;
  • submit two distinct fresh reports; count advances twice.

B-05 — Controller transfer creates an authority dead-end

Severity: Blocker for documented operations
Status: Confirmed integration defect

DepegEventRegistry has one controller. The documented deployment flow transfers that role to StableGuardCREReceiver. The integration test does exactly that.

Relevant code:

After transfer, only the receiver contract can call controller-gated methods. However, the receiver exposes no governance or forwarding entry point for:

  • retryFailedDestinations();
  • supersede();
  • manual initiateRecovery();
  • future transferController();
  • a customer-held early-unlock or override path described in ACTION_RECORD.md.

An EOA or multisig cannot call those registry functions because it is no longer controller. The receiver cannot spontaneously call them because no external function instructs it to do so.

This means several advertised recovery and governance paths become unreachable in the deployed composition even though they are callable in isolated registry tests.

Recommended repair

Replace the single controller with explicit capabilities:

REPORTER_ROLE
    accepts authenticated observations and score transitions

ACTION_ROLE
    records destination callbacks and dispatch results

RETRY_ROLE / KEEPER_ROLE
    retries or times out delivery attempts

GOVERNANCE_ROLE
    changes configuration, supersedes eligible incidents,
    authorizes exceptional recovery, rotates roles

PAUSE_COORDINATOR_ROLE
    acquires/releases physical vault holds

A multisig or timelocked governance contract should retain governance and role-rotation authority. The CRE receiver should receive only the minimum roles required for automatic operation.

If the single-controller model is retained, the receiver needs explicit, access-controlled forwarding methods for every operation that must remain reachable. That is less clean but still better than an unreachable authority graph.

Missing test

Deploy exactly as intended, transfer control to the receiver, and prove that the designated governance identity can still:

  • retry a failed destination;
  • apply an allowed override;
  • rotate the receiver/controller;
  • supersede a pre-protection incident;
  • recover from a receiver upgrade or key compromise.

II. Asynchronous delivery and reconciliation

H-01 — A destination slot has no delivery-attempt identity

Severity: High before real CCIP/asynchronous integration
Status: Confirmed design gap

A Destination contains only:

chainSelector
vault
state

A callback contains only:

eventId
destIndex
newDestState

A retry changes only:

FAILED -> PENDING

Relevant code:

Dangerous ordering

attempt 1 dispatched
attempt 1 reports FAILED
slot becomes FAILED

attempt 2 is queued
slot becomes PENDING

late callback from attempt 1 arrives before attempt 2 callback
slot is currently PENDING
old callback is accepted as the result of attempt 2

The sticky-slot rule correctly prevents a callback from overwriting COMPLETE. It does not distinguish two different attempts that occupy the same PENDING state at different times.

The existing stale-callback test covers:

retry succeeds -> slot COMPLETE -> stale FAILED arrives

That case is rejected. The untested and dangerous interval is:

retry requeued -> slot PENDING -> stale old callback arrives -> new callback has not arrived yet

Required identity

Destination identity != Delivery-attempt identity

Recommended attempt record:

enum Phase { PROTECTION, RECOVERY }

enum AttemptState { PENDING, COMPLETE, FAILED, TIMED_OUT, SUPERSEDED }

struct DeliveryAttempt {
    bytes32 eventId;
    Phase phase;
    bytes32 destinationKey;
    uint32 attemptNo;
    bytes32 messageId;
    uint64 startedAt;
    uint64 deadline;
    AttemptState state;
}

The callback must bind at least:

eventId
phase
destinationKey
attemptNo
messageId
result

A callback for attempt n must never be able to settle attempt n+1.

Missing test

1. attempt 1 -> FAILED
2. queue attempt 2 -> PENDING
3. deliver late COMPLETE or FAILED from attempt 1
4. assert rejection because messageId/attemptNo is stale
5. deliver attempt 2 callback
6. assert only attempt 2 changes current destination state

H-02 — A retried destination can become permanently non-retryable

Severity: High
Status: Confirmed liveness gap

retryFailedDestinations() is allowed only from PARTIALLY_PROTECTED or PARTIALLY_RECOVERED and changes a failed slot to PENDING. settlePending() is explicitly not available in the PARTIALLY_* states.

If the retried asynchronous message never returns:

slot remains PENDING
retry cannot be called again because slot is not FAILED
settlePending cannot be called because event is PARTIALLY_*

The only remaining bound is the outer event TTL. That can leave the system unable to retry for the full incident lifetime.

Recommended repair

Give each attempt its own deadline and a permissionless timeout transition:

PENDING --after attemptDeadline--> TIMED_OUT
TIMED_OUT -> eligible for next attempt

The event’s outer TTL should cap the incident; it should not substitute for per-attempt liveness.

Missing test

PARTIALLY_PROTECTED
failed destination retried
new attempt never callbacks
attempt deadline passes
timeout marks attempt TIMED_OUT
next retry is permitted before outer eventTTL

H-03 — pendingTTL collapses partial physical truth into global FAILED

Severity: High
Status: Confirmed

Consider two destinations:

destination A = COMPLETE
destination B = PENDING

Because not all destinations are settled, the event remains PROTECTION_PENDING. After pendingTTL, either settlePending() or a subsequent callback terminates the entire event as FAILED.

Relevant code:

The ledger then says:

Event = FAILED

while physical reality says:

A is protected
B timed out

This discards exactly the partial-delivery state that PARTIALLY_PROTECTED and PARTIALLY_RECOVERED were introduced to preserve.

Recommended repair

At pending timeout:

  1. mark only still-PENDING attempts as TIMED_OUT;
  2. preserve already COMPLETE destinations;
  3. evaluate the aggregate state;
  4. produce:
    • PROTECTED if all effective destinations completed;
    • PARTIALLY_PROTECTED if at least one completed and at least one failed/timed out;
    • FAILED only if none completed;
  5. mirror the same logic for recovery.

The event state should summarize destination truth, not erase it.

Missing tests

  • one complete + one pending at timeout → PARTIALLY_PROTECTED;
  • one complete + one pending at recovery timeout → PARTIALLY_RECOVERED;
  • all pending time out → FAILED;
  • completed destination remains queryable and unchanged after timeout.

H-04 — Physical action and ledger acknowledgement can diverge permanently

Severity: High
Status: Confirmed reconciliation gap

The receiver performs the physical action first and then reports the result to the registry.

Recovery divergence

vault.unpause() succeeds
registry.destinationCallback(...) reverts

The receiver emits RegistryCallbackFailed, but the physical vault is already unpaused. On the next report, the event may still be RECOVERY_PENDING; however, because vault.paused() is now false, the receiver skips the callback block entirely and only attempts finalizeRecovery(). finalizeRecovery() cannot succeed while the destination remains PENDING.

Relevant code:

The event can therefore remain pending or eventually become failed/expired while the vault is physically unpaused.

Protection divergence

A real Pausable implementation commonly reverts on pause() when already paused. The current mock appears to model idempotent behavior, which hides the distinction between:

pause call reverted because vault was already safely paused

and:

pause action failed and vault remains unprotected

The receiver treats any pause() revert as FAILED, even if the postcondition vault.paused() == true is already satisfied.

Required invariant

The action record must be based on verified postcondition, not only call return.

For pause:

success <=> vault.paused() == true

For unpause:

success <=> vault.paused() == false
AND aggregate active hold count == 0

Recommended repair

Use explicit reconciliation:

1. read desired physical state from hold coordinator;
2. attempt action only if current state differs;
3. re-read physical state;
4. record COMPLETE if the required postcondition is true;
5. if the registry callback fails, retain a reconciliation job keyed by
   event/phase/destination/attempt and retry the acknowledgement independently.

Do not make future acknowledgement conditional on repeating the physical action.

Missing tests

  • unpause succeeds, callback reverts once, next report reconciles the destination to COMPLETE without calling unpause again;
  • pause reverts because vault is already paused, postcondition check records protection success;
  • pause reverts and vault remains unpaused, destination records failure;
  • registry says complete but physical state disagrees, reconciliation detects and corrects/escalates.

H-05 — The declared outer-TTL priority is not global

Severity: High
Status: Confirmed deterministic-state defect

The code comment says eventTTL always wins, and some paths enforce it. Other mutating paths do not.

Examples:

  • processReport() / _applyScoreTransitions() checks outer TTL;
  • destinationCallback() checks outer TTL;
  • initiateRecovery() checks outer TTL;
  • finalizeRecovery() does not;
  • settlePending() does not first apply outer TTL;
  • supersede() does not first apply outer TTL;
  • retryFailedDestinations() does not first apply outer TTL.

Relevant code:

After the same outer deadline, terminal state can depend on which function is called first:

settleExpired()      -> EXPIRED
settlePending()      -> FAILED
finalizeRecovery()   -> NORMAL
supersede()          -> SUPERSEDED (for eligible pre-protection states)

If the specification says the outer TTL has highest priority, this is nondeterministic relative to call ordering.

Recommended repair

Centralize expiry as a guard on every mutating entry point:

modifier applyOuterExpiry(bytes32 eventId) {
    DepegEvent storage ev = _events[eventId];
    _requireExists(ev);
    if (!_isTerminal(ev.state) && block.timestamp >= ev.createdAt + eventTTL) {
        _terminate(eventId, _coinKey(ev.coin), State.EXPIRED);
        return;
    }
    _;
}

Because Solidity modifiers with early return need careful design, an internal helper returning a boolean may be clearer:

if (_expireIfDue(eventId)) return;

Apply one documented precedence order everywhere. If some transitions are intentionally allowed after outer TTL, state that explicitly instead of calling G13 universal.

Missing tests

At createdAt + eventTTL, invoke every mutating path in multiple orders and assert the same terminal result.


H-06 — Protection and recovery overwrite one another’s destination history

Severity: High for auditability and asynchronous correctness
Status: Confirmed

initiateRecovery() deletes the protection destinations and repopulates the same array for recovery. Auto-recovery reuses the protection array and resets every slot to PENDING.

Relevant code:

Consequences:

  1. the final onchain event no longer contains the protection delivery record;
  2. a late protection callback cannot be distinguished structurally from a recovery callback if the same index is now pending;
  3. the meaning of destinations[i].state changes by phase without phase being part of the slot identity;
  4. forensic reconstruction requires event logs and assumptions rather than durable phase records.

Recommended repair

Use separate append-only phase records:

struct PhaseRecord {
    Phase phase;
    uint64 openedAt;
    uint64 closedAt;
    DeliveryAttempt[] attempts;
}

mapping(bytes32 eventId => PhaseRecord protection);
mapping(bytes32 eventId => PhaseRecord recovery);

Or store a monotonically increasing phase ID and make every callback phase-qualified. Do not mutate a protection result into a recovery request.


H-07 — Auto-recovery resurrects SUPERSEDED destinations

Severity: High
Status: Confirmed semantic contradiction

During protection, SUPERSEDED is treated as settled but excluded from the effective destination count. This allows the event to reach PROTECTED without that destination completing.

Auto-recovery then loops over every stored destination and sets every state to PENDING, including destinations previously marked SUPERSEDED.

Relevant code:

A destination declared no longer relevant during protection becomes required again during recovery without a reactivation decision.

Recommended repair

Either:

  • keep SUPERSEDED excluded across all later phases for the incident; or
  • create an explicit governance transition reactivateDestination() with a reason and new destination generation.

A blanket reset must not erase exclusion semantics.

Missing test

Protect three destinations with one SUPERSEDED, trigger auto-recovery, and assert the superseded destination remains excluded unless explicitly reactivated.


H-08 — recoveryCooldown currently delays ledger closure, not physical reopening

Severity: High if cooldown is intended as a safety delay; otherwise specification mismatch
Status: Confirmed behavior, policy meaning must be clarified

The receiver unpauses the vault as soon as the incident enters RECOVERY_PENDING. finalizeRecovery() applies recoveryCooldown only before changing the event to NORMAL.

Therefore:

physical vault reopening happens first
cooldown delays only bookkeeping closure

Relevant code:

If the intended policy is “observe stability for N reports, then wait a cooldown before reopening,” the current implementation does not enforce it. If the intended policy is “reopen immediately after N stable reports but delay terminal record closure,” then the name and documentation should say that explicitly.

Recommended repair

Choose one of two explicit policies:

Safety-delay policy

PROTECTED
-> STABILITY_CONFIRMED
-> wait recoveryCooldown
-> authorize hold release
-> physically unpause if aggregate hold count reaches zero
-> NORMAL after reconciled completion

Immediate-reopen policy

PROTECTED
-> stabilityWindow satisfied
-> release hold immediately
-> cooldown applies only to archival/closure

The first is safer. In either case, the physical transition and its authority must be represented explicitly.


H-09 — Event expiry and physical protection lifetime are coupled incorrectly

Severity: High
Status: Confirmed architectural cause of the resume problem

The current continuation logic exists because an event can expire while the vault remains paused. The system then creates a new independent PROTECTED event to continue counting stability.

This reveals that one object is being used for two lifetimes:

incident/event epoch lifetime
physical protection-hold lifetime

Those lifetimes are not necessarily equal. A record may need to close, rotate, or archive while the physical protection obligation remains active.

Recommended repair

Separate them:

RootIncident
    durable causal identity

EventEpoch
    bounded observation/processing window
    may expire and be replaced

ProtectionHold
    remains active until an authorized recovery condition releases it

An event epoch expiry must never fabricate, remove, or transfer a physical hold. A successor epoch references the same rootIncidentId and active holdId.

This eliminates the need to infer continuation from paused() and prevents indefinite chains of unrelated fresh incidents.


III. Evidence, identity, and source binding

E-01 — The Data Streams evidence chain disappears before the action record

Severity: High for evidence-backed automation
Status: Confirmed

The workflow retrieves fullReport and validFromTimestamp. It forwards fullReport in the payload, but the receiver decodes and discards it. The receiver passes bytes32(0) as evidenceRoot to every registry transition. The registry records its own block.timestamp rather than the source observation time.

Relevant code:

The resulting action event is not cryptographically linked to the Data Streams evidence that caused it.

Required separation

Store at least:

sourceObservedAt  = time claimed/verified by source report
acceptedAt        = block.timestamp when consumer accepted it
workflowId        = authenticated workflow identity
sequence          = monotonic accepted report sequence
evidenceDigest    = digest of the verified source evidence and normalized observation

Do not overwrite the only root on every extension. Use an append-only event or rolling accumulator:

newAccumulator = keccak256(oldAccumulator, observationId, evidenceDigest)

For forensic access, emit one ObservationAccepted event per asset with the full identity tuple.

Gas-conscious design

The full signed report does not need to be stored onchain. Store its digest and preserve the full bytes offchain in a content-addressed evidence package. The digest must be computed before discarding the bytes.


E-02 — Feed-to-asset binding is positional and not independently verified

Severity: High
Status: Confirmed

The workflow requests one report per configured coin, stores results in an array, then later pairs dsReports[i] with coins[i]. It does not verify that the returned report.feedID equals the configured coin.feedId before using the price.

Relevant code:

The onchain receiver also receives no feedId, so it cannot validate the mapping independently.

Recommended repair

For every observation, carry and verify:

assetId
feedId
source domain
raw source price
sourceObservedAt
evidenceDigest

The workflow must verify report.feedID == configured feedId. The receiver should maintain an allowlist:

allowedFeed[assetId] == feedId

If verification of the full Data Streams report occurs elsewhere, bind the verified digest to that exact tuple.


E-03 — Exposure binding proves configured intent, not actual holdings

Severity: High for claim accuracy; implementation-dependent for runtime risk
Status: Confirmed scope mismatch

ExposureRegistry is a manual admin registry. Its own comment says Phase 1 uses manual registration. Yet receiver comments and ACTION_RECORD.md describe the vault as “demonstrably” or “provably” holding the asset.

Relevant code and documentation:

What is currently proven is narrower:

an asset not registered in the configured exposure set cannot enter the pause path

What is not proven is:

registered exposure == current actual vault holdings

A stale or incorrect admin registration passes the gate even if the vault no longer holds the asset.

Two valid resolutions

A. Narrow the claim

Rename it ConfiguredExposureRegistry and state exactly what it proves.

B. Strengthen the mechanism

Reconcile configured exposure against authoritative vault accounting or token balances, with a clearly defined treatment of:

  • wrapped assets;
  • debt exposure;
  • LP-token indirect exposure;
  • cross-chain representations;
  • temporarily zero balances;
  • delegated/custodied positions.

The second option needs an explicit exposure ontology; a simple ERC-20 balanceOf check is not sufficient for every vault model.


E-04 — Asset identity is ambiguous and not chain-qualified

Severity: High in a multi-chain design
Status: Confirmed

ExposureRegistry calls its key a symbol and gives keccak256("USDC") as an example. The receiver and tests instead use a token address padded into bytes32. The production and staging configs run on Sepolia but contain Ethereum mainnet token addresses.

Relevant code:

An address is not globally unique across chains, and a symbol is not unique even within one ecosystem.

Recommended canonical identity

bytes32 assetId = keccak256(
    abi.encode(
        sourceChainSelector,
        canonicalTokenAddress,
        representationType
    )
);

If the system acts on a destination-chain representation, distinguish:

canonical economic asset
source feed identity
destination token contract
vault position identity

Do not call all four “coin” or “symbol.”


E-05 — The receiver trusts mutually redundant fields without checking consistency

Severity: High
Status: Confirmed

The payload contains:

price
deviationBps
signalLevel
compositeScore
marketStress

The receiver stores or acts on these values but does not verify that they agree. A report can claim:

price = $1.00
deviation = 0
signalLevel = EXIT

and the action path follows signalLevel.

The workflow is expected to be trusted after workflow authentication, but redundant unverified fields widen the semantic attack and error surface. They also make future workflow upgrades capable of changing action semantics without an onchain invariant.

Recommended repair

Choose one canonical source input and derive cheap fields onchain, or validate consistency:

recompute deviation from raw price and configured peg
recompute signal from threshold configuration
recompute composite score from accepted per-asset signals

If gas cost requires offchain derivation, commit to a versioned policy hash:

policyId / thresholdSetHash / scoringVersion

and verify that the report names an allowed policy version.


E-06 — Source timestamp and freshness are not enforced

Severity: High
Status: Confirmed

validFromTimestamp is retrieved but not used in the action payload. The payload uses nowSec from workflow execution. The receiver writes lastObservedAt without monotonicity or maximum-age checks, and the registry replaces it with block.timestamp.

A stale source report can therefore be transformed into a fresh-looking workflow report.

Recommended repair

Carry both:

sourceObservedAt
workflowGeneratedAt

Verify:

sourceObservedAt <= workflowGeneratedAt
workflowGeneratedAt <= block.timestamp + allowedClockSkew
block.timestamp - sourceObservedAt <= maxSourceAge
sourceObservedAt > lastSourceObservedAt[assetId]
sequence > lastSequence

Time is not a substitute for sequence, but both are useful.


IV. Receiver and workflow hardening

Several items in this section are hardening consequences or implementation surfaces of earlier root findings rather than separate root defects. In particular, duplicate-asset handling within R-01 supports B-04, R-09 is part of B-04/E-06, and R-10 is an efficiency/evidence consequence of E-01. They remain listed for implementation completeness, but should not be counted as independent findings where those dependencies apply.

R-01 — Array shape, uniqueness, and batch bounds are unchecked

Severity: Medium to High
Status: Confirmed

The receiver decodes parallel arrays and indexes prices[i], deviationsBps[i], and signalLevels[i] for every coins[i] without first requiring equal lengths. It also does not bound batch size, reject zero addresses, or reject duplicate asset identities.

Consequences:

  • a short parallel array reverts the whole report;
  • duplicate assets can advance one incident multiple times;
  • an oversized report can make action execution exceed practical gas limits;
  • one malformed coin blocks every well-formed coin in the same batch.

Recommended checks

coins.length > 0
coins.length <= MAX_BATCH
prices.length == coins.length
deviationsBps.length == coins.length
signalLevels.length == coins.length
fullReports.length == coins.length
no coin == address(0)
no duplicate assetId
signalLevel within enum range

Decide deliberately whether malformed input rejects the whole signed report or isolates one observation. For authenticated reports, atomic rejection is usually preferable because partial interpretation of a malformed envelope can hide workflow defects.


R-02 — Per-coin failure isolation is incomplete

Severity: Medium
Status: Confirmed

processReport(), pause(), unpause(), and some registry callbacks are wrapped in try/catch. However, external view calls such as vault.paused() and exposureRegistry.isExposed() are outside try/catch. A revert from either dependency reverts the entire onReport() transaction.

The current comment suggests per-coin isolation, but it is not complete across dependency boundaries.

Recommended repair

Either:

  • deliberately make the report atomic and remove the partial-isolation implication; or
  • move every external dependency behind a per-coin adapter call that can be caught and recorded.

For safety actions, atomicity may be the cleaner default if a partially applied multi-asset report would create inconsistent interpretation. The correct choice should be explicit.


R-03 — Negative Data Streams prices are converted to positive values

Severity: Medium
Status: Confirmed fail-open conversion

The workflow applies absolute value:

const price18 = raw >= 0n ? raw : -raw

with the comment that stablecoin prices are never negative.

Relevant code:

If negative values are impossible by specification, receiving one is evidence of malformed data, decoding error, feed mismatch, or upstream corruption. Converting it into a plausible positive value destroys that evidence.

Recommended repair

require raw > 0
require raw within configured plausible bounds
fail closed on violation
emit/store reason in the detection record

R-04 — Configured chain selector is not the selector used by the workflow

Severity: Medium
Status: Confirmed

Config includes chainSelectorName, but main.ts constructs EVMClient(SEPOLIA_CHAIN_SELECTOR) using a hardcoded constant. The configuration therefore appears authoritative while not controlling execution.

Relevant code:

Recommended repair

Resolve the configured chain selector to the exact numeric selector and use that value for:

  • EVM client construction;
  • report domain binding;
  • asset identity;
  • receiver configuration;
  • destination validation.

Reject configuration if those values disagree.


R-05 — The workflow computes a cooldown policy surface that does not control the payload

Severity: Medium
Status: Confirmed dead-policy surface

The workflow reads COOLDOWN(), computes triggerable = results.filter(...), and comments that the receiver enforces lastTriggered. The encoded payload nevertheless contains all results, and the current receiver has no lastTriggered cooldown guard matching that comment.

Relevant code:

Dead policy code is dangerous because reviewers may believe a limit is enforced when it is not.

Recommended repair

Either remove the obsolete cooldown read/filter and update comments, or define exactly which transition it controls and enforce it in one authoritative layer.


R-06 — Constructor and destination configuration validation is insufficient

Severity: Medium
Status: Confirmed

DepegEventRegistry assigns constructor parameters without validating:

  • zero controller;
  • watchThreshold <= confirmedThreshold;
  • nonzero stabilityWindow;
  • nonzero and coherent TTLs;
  • relation between pendingTTL, recoveryCooldown, and eventTTL.

Destination inputs are not checked for zero vault addresses or duplicates.

Recommended minimum validation

controller != 0
watchThreshold < confirmedThreshold
stabilityWindow > 0
pendingTTL > 0
eventTTL > pendingTTL
recoveryCooldown < eventTTL
all destination vaults != 0
all destination keys unique within a phase

The precise inequalities depend on policy, but invalid states should be rejected at construction rather than discovered after deployment.


R-07 — Empty effective destination sets have no defined closure

Severity: Medium
Status: Confirmed

If every destination is SUPERSEDED, effectiveTotal == 0. _evaluateProtection() and _evaluateRecovery() return and wait for TTL. finalizeRecovery() also rejects effectiveTotal == 0.

This leaves an incident in a nonterminal state despite having no remaining effective work.

Recommended repair

Specify one policy:

  • terminal SUPERSEDED for the phase/incident;
  • governance-required closure;
  • NO_EFFECTIVE_DESTINATIONS explicit state;
  • hard rejection of superseding the last effective destination.

Do not leave the result implicit until TTL.


R-08 — Callback result values are not phase-restricted

Severity: Medium
Status: Confirmed

destinationCallback() accepts any DestState value supplied by the controller, including PENDING. The enum is ABI-range checked, but semantic transition validity is not.

A callback should normally settle an attempt into a terminal attempt state, not rewrite it to PENDING. SUPERSEDED may also require governance authority rather than a delivery callback.

Recommended repair

Separate functions or validate by origin:

recordDeliveryResult(... COMPLETE | FAILED)
markAttemptTimedOut(...)
supersedeDestination(...) only governance
queueRetry(...) creates new PENDING attempt

R-09 — lastObservedAt can move backward

Severity: Medium
Status: Confirmed

The receiver assigns lastObservedAt = observedAt for every accepted call without checking monotonicity. A replayed or stale report can roll the public latest-observation field backward even before considering state transitions.

This should be closed as part of B-04/E-06 with sequence and freshness checks.


R-10 — Large evidence bytes are transmitted onchain and then ignored

Severity: Medium / efficiency
Status: Confirmed

fullReports are included in the ABI payload, decoded, and discarded. This increases calldata and verification cost without contributing to onchain evidence.

If the receiver does not verify or store the full reports, transmit only a digest plus the normalized verified fields. If onchain verification is intended later, make that boundary explicit and do not describe the current path as evidence-bound until it exists.


V. Documentation consistency

D-01 — ACTION_RECORD.md contains two incompatible recovery descriptions

Severity: Documentation defect
Status: Confirmed

Section 1.3 says recovery is not wired into the CRE workflow and requires an explicit controller call. Section 3.5 later says the default path is fully automatic and no manual call is required.

Relevant locations:

Update section 1.3 to describe the current branch and then list the unresolved authority/reconciliation conditions identified above.


D-02 — Replay safety is currently stated too broadly

Severity: Documentation/assurance defect
Status: Confirmed

The sticky-slot rule proves:

once this slot is no longer PENDING, a later callback cannot overwrite it

It does not prove:

a callback belongs to the current attempt

After a retry returns the slot to PENDING, an old callback is acceptable unless attempt identity is added. The assurance claim should be narrowed until H-01 is fixed.


D-03 — “Correct by construction under asynchronous CCIP” is premature

Severity: Documentation/assurance defect
Status: Confirmed

The local state machine is stronger than before, but asynchronous correctness requires at least:

  • phase-qualified callback identity;
  • attempt/message identity;
  • per-attempt timeout;
  • stale-attempt rejection;
  • physical/ledger reconciliation;
  • preserved partial-delivery truth.

Until those are implemented and tested with realistic delayed/reordered callbacks, the honest statement is:

The local destination-state guards are implemented and tested.
End-to-end asynchronous correctness remains unproven.

That wording is fully consistent with the transparent style already used elsewhere in the record.


VI. Two valid implementation routes

The current system can be completed in either of two coherent ways.

Route A — Scope reduction: one asset per receiver/vault domain

This is the smaller route.

Structural rules

one receiver instance binds exactly one assetId
one receiver instance binds exactly one vault
one active root incident domain
no multi-coin action loop
reports for any other asset are rejected

Still required

Even with scope reduction, the implementation still needs:

  • exact workflow identity validation;
  • report sequence/freshness/replay protection;
  • delivery-attempt identity;
  • deterministic TTL precedence;
  • physical/ledger reconciliation;
  • evidence digest lineage;
  • reachable governance and controller rotation.

Benefit

It eliminates the shared-actuator conflict and the cross-asset fabricated-resume path by construction. It is appropriate if the intended product is a dedicated protection controller per stablecoin vault.

Route B — Full multi-asset architecture

This is the more general route and fits the current batched monitoring design.

Required additional object

Introduce a VaultProtectionCoordinator that owns the physical pause/unpause authority and maintains asset/incident-scoped holds.

Observation accepted
    -> asset incident advances
    -> incident acquires/releases its own hold
    -> coordinator derives global vault desired state
    -> actuator reconciles physical state
    -> action acknowledgement is recorded independently

Benefit

Multiple assets can share one vault safely, and one recovered asset cannot release another asset’s protection requirement.

Cost

More state and more tests, but the semantics become explicit and extensible to cross-chain destinations.


VII. Recommended target model

The cleanest completion separates four identities.

1. Observation identity

struct Observation {
    bytes32 observationId;
    bytes32 workflowId;
    uint64 sequence;
    bytes32 assetId;
    bytes32 feedId;
    uint64 sourceObservedAt;
    uint64 acceptedAt;
    bytes32 evidenceDigest;
    uint8 policyVersion;
}

Invariant:

one observation can affect one asset incident at most once

2. Root incident and event epoch identity

struct IncidentEpoch {
    bytes32 eventId;
    bytes32 rootIncidentId;
    bytes32 parentEventId;
    bytes32 assetId;
    IncidentState state;
    uint64 createdAt;
    uint64 expiresAt;
    uint32 stableObservationCount;
    uint64 lastObservationSequence;
}

Invariant:

an epoch may expire, but causal lineage remains explicit

3. Physical hold identity

struct VaultHold {
    bytes32 holdId;
    bytes32 rootIncidentId;
    bytes32 assetId;
    address vault;
    bool active;
}

Invariant:

physical unpause occurs only when the aggregate active hold set is empty

4. Delivery-attempt identity

struct DeliveryAttempt {
    bytes32 attemptId;
    bytes32 eventId;
    Phase phase;
    bytes32 destinationKey;
    uint32 attemptNo;
    bytes32 messageId;
    uint64 startedAt;
    uint64 deadline;
    AttemptState state;
}

Invariant:

only the callback bound to the current attempt may settle that attempt

VIII. Required regression matrix

The following tests should be added before describing the system as complete.

Shared actuator and causal lineage

  1. USDC and USDT both PROTECTED; USDC recovers; USDT remains HEDGE; vault remains paused.
  2. [USDC=HEDGE, USDT=STABLE] does not create a USDT continuation.
  3. Reversing the array order produces the same logical result.
  4. A pre-paused vault with no DepegGuard hold creates no incident.
  5. An expired USDC event cannot authorize a USDT continuation.
  6. A successor epoch must reference the same root incident and active hold.
  7. Releasing the final hold unpauses; releasing a nonfinal hold does not.

Report authority, replay, and freshness

  1. wrong workflow ID rejected;
  2. wrong workflow owner rejected;
  3. wrong chain domain rejected;
  4. wrong receiver domain rejected;
  5. identical report replayed three times advances stable count once;
  6. duplicate asset within one report rejected;
  7. lower/equal sequence rejected;
  8. stale sourceObservedAt rejected;
  9. future timestamp beyond allowed skew rejected;
  10. feed ID mismatch rejected;
  11. negative or zero source price rejected.

Delivery attempts

  1. attempt 1 late callback after attempt 2 is queued is rejected;
  2. attempt 1 and attempt 2 may have opposite results without ambiguity;
  3. retried attempt can time out and be retried again;
  4. callback with wrong phase rejected;
  5. callback with wrong destination key rejected;
  6. callback with wrong message ID rejected.

Partial truth and reconciliation

  1. one complete + one timed out → PARTIALLY_PROTECTED;
  2. one recovered + one timed out → PARTIALLY_RECOVERED;
  3. unpause succeeds, registry acknowledgement fails once, next cycle reconciles;
  4. already-paused real vault is recognized by postcondition rather than marked failed;
  5. physical state disagreement with ledger is detected;
  6. protection history remains queryable after recovery starts;
  7. SUPERSEDED destination remains excluded through recovery.

TTL and closure determinism

  1. every mutating function called after outer TTL produces the same specified result;
  2. settlePending after outer TTL cannot produce a different terminal state;
  3. finalizeRecovery after outer TTL follows the documented precedence;
  4. empty effective destination set has an explicit closure state;
  5. event epoch expiry does not release or fabricate a physical hold.

Authority and upgradeability

  1. after deployment, governance can still retry, rotate roles, and apply documented overrides;
  2. compromised receiver can be revoked without redeploying the registry;
  3. governance cannot forge an observation unless explicitly granted reporter authority;
  4. automatic receiver cannot use governance-only supersede/override paths.

IX. Implementation order

Phase 0 — Do not attach real value-moving authority yet

Keep the current branch test-only until B-01 through B-05 are resolved.

Phase 1 — Repair object boundaries

  1. choose Route A or Route B;
  2. introduce asset-qualified holds or enforce one-asset scope;
  3. remove pause-bit-based causal inference;
  4. add root incident and parent epoch lineage;
  5. separate registry roles.

Phase 2 — Secure report acceptance

  1. validate workflow ID/owner metadata;
  2. add domain binding;
  3. add monotonic sequence and freshness;
  4. reject duplicate assets and malformed arrays;
  5. bind feed ID and asset ID;
  6. derive or validate action fields.

Phase 3 — Complete asynchronous semantics

  1. add phase and attempt identity;
  2. add per-attempt deadlines;
  3. preserve partial truth on timeout;
  4. separate protection and recovery records;
  5. preserve SUPERSEDED semantics;
  6. implement physical/ledger reconciliation.

Phase 4 — Evidence and hardening

  1. carry evidence digest and source time;
  2. update exposure claim or strengthen reconciliation;
  3. validate constructors/configuration;
  4. remove dead cooldown policy code;
  5. update ACTION_RECORD.md to one consistent current description;
  6. run the full regression matrix, then real delayed/reordered delivery tests.

X. Completion criteria

I would consider this layer complete when the following invariants are structural rather than procedural assumptions:

I1. An observation is accepted once, for the intended workflow and domain.

I2. An asset incident cannot be created, resumed, or closed from another
    asset's physical state.

I3. One incident can release only its own protection hold.

I4. A vault is physically unpaused only when its aggregate active hold set is empty.

I5. Every destination callback is bound to one phase and one delivery attempt.

I6. Timeout preserves completed physical work and records partial truth.

I7. Event terminal state is independent of transaction ordering at the same deadline.

I8. Physical actuator state and the action ledger are eventually reconciled after
    either side succeeds while the other side temporarily fails.

I9. The action record is cryptographically linked to the accepted source evidence.

I10. Governance, retry, override, and role-rotation paths remain reachable after
     the receiver is installed, without giving the receiver unnecessary authority.

Final assessment

The implementation has advanced materially. The original three invariants were not ignored or cosmetically patched; they were integrated into the state machine and documented. The next defects are therefore mostly second-order defects: they arise where asset-scoped logic, vault-scoped physical authority, report identity, and asynchronous delivery identity meet.

The central correction is not another isolated guard. It is to stop treating these pairs as equivalent:

processReport call          == fresh observation
coin event                  == vault protection authority
vault.paused()              == proof of this coin's incident
PENDING destination slot    == current delivery attempt
one mutable destination list == complete phase history

Once those identities are separated, the rest of the repair becomes systematic rather than ad hoc. The existing work can remain as the event-policy layer; it needs a qualified observation boundary above it, a hold coordinator beside it, and an attempt-qualified delivery ledger below it.

That would turn the current local state-machine proof into a coherent end-to-end protection architecture.

This is genuinely one of the most thorough pieces of technical review I’ve ever received — thank you, sincerely. I also noticed the timestamps: I sent the implementation at 1am and had this back by 3:30am. That level of care at that hour doesn’t go unnoticed.

The closing summary is the part that’s going to stick with me: the five equivalences you listed (processReport==observation, coin event==vault authority, paused()==proof of cause, PENDING slot==current attempt, one mutable list==full history) are exactly the assumptions I’d been building on without realising I’d made them. Seeing them named as a single root cause, rather than as 27 separate bugs, makes the path forward genuinely clear rather than overwhelming.

What I’m going to do:

  1. Treat this branch as test-only until B-01 through B-05 are resolved, per your Phase 0 — no real value-moving authority until then.
  2. Go with Route B (full multi-asset architecture). The batched, multi-coin monitoring design is the actual product I’m building, so I want the VaultProtectionCoordinator done properly rather than scoping down to one-asset-per-vault.
  3. Work through Phases 1–4 in the order you laid out, starting with the object-boundary repairs (root incident/epoch separation, asset-qualified holds, removing pause-bit causal inference) before touching report acceptance or async delivery.
  4. Build out the regression matrix as I go — all 40 tests, not just the ones tied to whatever I happen to be fixing that day.
  5. Fix ACTION_RECORD.md’s contradiction and overclaims (D-01–D-03) as part of the same pass, not as an afterthought.

I’ll come back once the object-boundary work (Phase 1) is done and share the diff, rather than sending incremental pieces — sounds like a coherent chunk of related changes is more useful to review than a stream of partial ones.

Thanks again — this genuinely changes how I think about the whole architecture, not just the bugs in front of me.

Matthew

Matthew,
I really appreciate this response.

The fact that the review helped expose the common assumptions behind the individual findings was exactly the outcome I was hoping for. Your plan to repair the object boundaries first and work through the phases in order makes sense.

Looking forward to seeing what you build from here.