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

**URL:** <https://forum.arbitrum.foundation/t/stableguard-by-fintechcheck-real-time-stablecoin-depeg-detection-for-treasury-defi-protection/31204>\
**Category:** General\
**Tags:** proposal, proposal-discussions\
**Created:** [August 13, 2026, 10:54am UTC](https://forum.arbitrum.foundation/t/stableguard-by-fintechcheck-real-time-stablecoin-depeg-detection-for-treasury-defi-protection/31204 "2026-08-13T10:54:23Z")\
**Posts on this page:** 13\
**Page:** 1

<div class="post-metadata">

**Author:** ![Matthew77](https://avatars.discourse-cdn.com/v4/letter/m/7ba0ec/32.png) [@Matthew77](https://forum.arbitrum.foundation/u/Matthew77)\
**Post date:** [August 13, 2026, 10:54am UTC](https://forum.arbitrum.foundation/t/stableguard-by-fintechcheck-real-time-stablecoin-depeg-detection-for-treasury-defi-protection/31204/1 "2026-08-13T10:54:23Z")

</div>

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

---

<div class="post-metadata">

**Author:** ![cxclrfx](https://avatars.discourse-cdn.com/v4/letter/c/eb9ed0/32.png) [@cxclrfx](https://forum.arbitrum.foundation/u/cxclrfx)\
**Post date:** [August 13, 2026, 4:55pm UTC](https://forum.arbitrum.foundation/t/stableguard-by-fintechcheck-real-time-stablecoin-depeg-detection-for-treasury-defi-protection/31204/2 "2026-08-13T16:55:06Z")

</div>

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.

---

<div class="post-metadata">

**Author:** ![Matthew77](https://avatars.discourse-cdn.com/v4/letter/m/7ba0ec/32.png) [@Matthew77](https://forum.arbitrum.foundation/u/Matthew77)\
**Post date:** [August 13, 2026, 6:21pm UTC](https://forum.arbitrum.foundation/t/stableguard-by-fintechcheck-real-time-stablecoin-depeg-detection-for-treasury-defi-protection/31204/3 "2026-08-13T18:21:34Z")

</div>

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

---

<div class="post-metadata">

**Author:** ![cxclrfx](https://avatars.discourse-cdn.com/v4/letter/c/eb9ed0/32.png) [@cxclrfx](https://forum.arbitrum.foundation/u/cxclrfx)\
**Post date:** [August 13, 2026, 6:44pm UTC](https://forum.arbitrum.foundation/t/stableguard-by-fintechcheck-real-time-stablecoin-depeg-detection-for-treasury-defi-protection/31204/4 "2026-08-13T18:44:54Z")

</div>

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.

---

<div class="post-metadata">

**Author:** ![Matthew77](https://avatars.discourse-cdn.com/v4/letter/m/7ba0ec/32.png) [@Matthew77](https://forum.arbitrum.foundation/u/Matthew77)\
**Post date:** [August 13, 2026, 7:04pm UTC](https://forum.arbitrum.foundation/t/stableguard-by-fintechcheck-real-time-stablecoin-depeg-detection-for-treasury-defi-protection/31204/5 "2026-08-13T19:04:04Z")

</div>

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

---

<div class="post-metadata">

**Author:** ![MconnectDAO](https://yyz1.discourse-cdn.com/flex029/user_avatar/forum.arbitrum.foundation/mconnectdao/32/16356_2.png) [@MconnectDAO](https://forum.arbitrum.foundation/u/MconnectDAO)\
**Post date:** [August 14, 2026, 4:12am UTC](https://forum.arbitrum.foundation/t/stableguard-by-fintechcheck-real-time-stablecoin-depeg-detection-for-treasury-defi-protection/31204/6 "2026-08-14T04:12:34Z")

</div>

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

---

<div class="post-metadata">

**Author:** ![Matthew77](https://avatars.discourse-cdn.com/v4/letter/m/7ba0ec/32.png) [@Matthew77](https://forum.arbitrum.foundation/u/Matthew77)\
**Post date:** [August 14, 2026, 8:06am UTC](https://forum.arbitrum.foundation/t/stableguard-by-fintechcheck-real-time-stablecoin-depeg-detection-for-treasury-defi-protection/31204/7 "2026-08-14T08:06:37Z")

</div>

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.

---

<div class="post-metadata">

**Author:** ![Matthew77](https://avatars.discourse-cdn.com/v4/letter/m/7ba0ec/32.png) [@Matthew77](https://forum.arbitrum.foundation/u/Matthew77)\
**Post date:** [August 19, 2026, 4:16pm UTC](https://forum.arbitrum.foundation/t/stableguard-by-fintechcheck-real-time-stablecoin-depeg-detection-for-treasury-defi-protection/31204/8 "2026-08-19T16:16:15Z")

</div>

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.

---

<div class="post-metadata">

**Author:** ![Matthew77](https://avatars.discourse-cdn.com/v4/letter/m/7ba0ec/32.png) [@Matthew77](https://forum.arbitrum.foundation/u/Matthew77)\
**Post date:** [August 27, 2026, 12:22am UTC](https://forum.arbitrum.foundation/t/stableguard-by-fintechcheck-real-time-stablecoin-depeg-detection-for-treasury-defi-protection/31204/9 "2026-08-27T00:22:22Z")

</div>

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.

---

<div class="post-metadata">

**Author:** ![cxclrfx](https://avatars.discourse-cdn.com/v4/letter/c/eb9ed0/32.png) [@cxclrfx](https://forum.arbitrum.foundation/u/cxclrfx)\
**Post date:** [August 27, 2026, 2:46am UTC](https://forum.arbitrum.foundation/t/stableguard-by-fintechcheck-real-time-stablecoin-depeg-detection-for-treasury-defi-protection/31204/10 "2026-08-27T02:46:09Z")

</div>

# 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** :

```plaintext
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:

- [`StableGuardCREReceiver.sol`, recovery branch](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/contracts/StableGuardCREReceiver.sol#L144-L181)
- [`StableGuardCREReceiver.sol`, one immutable vault](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/contracts/StableGuardCREReceiver.sol#L70-L105)
- [`config.production.json`, four configured assets](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/depeg-monitor/config.production.json)

### Reachable trace

```plaintext
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`:

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

```

Formally:

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

```

### Recommended repair

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

```solidity
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:

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

```

Recovery becomes:

```plaintext
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:

```plaintext
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

```plaintext
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:

```solidity
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:

- [`StableGuardCREReceiver.sol`, continuation branch before exposure check](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/contracts/StableGuardCREReceiver.sol#L183-L205)
- [`DepegEventRegistry.sol`, fresh PROTECTED event construction](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/contracts/DepegEventRegistry.sol#L294-L334)

### Single-report reproduction

Take one report with ordered arrays:

```plaintext
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:

```plaintext
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:

```plaintext
[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

```plaintext
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:

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

```

The registry must verify:

```plaintext
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:

```solidity
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:

- [`StableGuardCREReceiver.sol`, metadata intentionally unused](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/contracts/StableGuardCREReceiver.sol#L94-L124)
- [Chainlink `ReceiverTemplate`, workflow ID/owner/name validation](https://github.com/smartcontractkit/cre-templates/blob/main/starter-templates/automation-migration/contracts/evm/src/ReceiverTemplate.sol)
- [Chainlink consumer-contract guidance](https://docs.chain.link/cre/guides/workflow/using-evm-client/onchain-write/building-consumer-contracts)

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

### Required invariant

```plaintext
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:

```plaintext
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:

- [`DepegEventRegistry.sol`, stableCount increments per call](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/contracts/DepegEventRegistry.sol#L436-L460)
- [`StableGuardCREReceiver.sol`, unbounded coin loop without uniqueness checks](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/contracts/StableGuardCREReceiver.sol#L107-L150)
- [Chainlink warning: signed reports can be replayed](https://docs.chain.link/cre/guides/workflow/using-evm-client/onchain-write/overview-go)

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`:

```plaintext
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

```plaintext
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

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

```

A stronger definition is:

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

```

### Recommended report envelope

```solidity
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:

```plaintext
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:

- [`DepegEventRegistry.sol`, single controller and transfer](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/contracts/DepegEventRegistry.sol#L118-L143)
- [`exposure-binding.test.js`, controller transferred to receiver](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/test/exposure-binding.test.js#L44-L76)

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:

```plaintext
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:

```solidity
chainSelector
vault
state

```

A callback contains only:

```solidity
eventId
destIndex
newDestState

```

A retry changes only:

```plaintext
FAILED -> PENDING

```

Relevant code:

- [`Destination` structure](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/contracts/DepegEventRegistry.sol#L44-L73)
- [`destinationCallback()`](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/contracts/DepegEventRegistry.sol#L206-L258)
- [`retryFailedDestinations()`](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/contracts/DepegEventRegistry.sol#L335-L357)

### Dangerous ordering

```plaintext
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:

```plaintext
retry succeeds -> slot COMPLETE -> stale FAILED arrives

```

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

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

```

### Required identity

```plaintext
Destination identity != Delivery-attempt identity

```

Recommended attempt record:

```solidity
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:

```plaintext
eventId
phase
destinationKey
attemptNo
messageId
result

```

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

### Missing test

```plaintext
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:

```plaintext
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:

```plaintext
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

```plaintext
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:

```plaintext
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:

- [`destinationCallback()`, timeout before recording the slot callback](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/contracts/DepegEventRegistry.sol#L225-L253)
- [`settlePending()`, whole-event termination](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/contracts/DepegEventRegistry.sol#L397-L424)

The ledger then says:

```plaintext
Event = FAILED

```

while physical reality says:

```plaintext
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

```plaintext
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:

- [`StableGuardCREReceiver.sol`, unpause then callback; callback skipped if already unpaused](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/contracts/StableGuardCREReceiver.sol#L160-L181)

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:

```plaintext
pause call reverted because vault was already safely paused

```

and:

```plaintext
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

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

```

For pause:

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

```

For unpause:

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

```

### Recommended repair

Use explicit reconciliation:

```plaintext
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:

- [`_applyScoreTransitions()`, G13](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/contracts/DepegEventRegistry.sol#L436-L460)
- [`finalizeRecovery()`](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/contracts/DepegEventRegistry.sol#L359-L379)
- [`supersede()` and settle helpers](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/contracts/DepegEventRegistry.sol#L381-L424)

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

```plaintext
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:

```solidity
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:

```solidity
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:

- [`initiateRecovery()`, `delete ev.destinations`](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/contracts/DepegEventRegistry.sol#L262-L291)
- [`_applyAutoRecovery()`, all destination states reset](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/contracts/DepegEventRegistry.sol#L482-L491)

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:

```solidity
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:

- [`_applyAutoRecovery()`](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/contracts/DepegEventRegistry.sol#L482-L491)
- [`_evaluateProtection()`, superseded excluded from effective total](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/contracts/DepegEventRegistry.sol#L493-L516)

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:

```plaintext
physical vault reopening happens first
cooldown delays only bookkeeping closure

```

Relevant code:

- [`StableGuardCREReceiver.sol`, immediate unpause](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/contracts/StableGuardCREReceiver.sol#L160-L180)
- [`finalizeRecovery()`, cooldown before NORMAL](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/contracts/DepegEventRegistry.sol#L359-L379)

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**

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

```

**Immediate-reopen policy**

```plaintext
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:

```plaintext
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:

```plaintext
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:

- [`main.ts`, Data Streams report fields](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/depeg-monitor/main.ts#L27-L54)
- [`main.ts`, full reports included in encoded payload](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/depeg-monitor/main.ts#L211-L225)
- [`StableGuardCREReceiver.sol`, fullReports discarded and zero evidence root used](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/contracts/StableGuardCREReceiver.sol#L107-L150)
- [`DepegEventRegistry.sol`, observedAt assigned from block time and root overwritten](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/contracts/DepegEventRegistry.sol#L543-L565)

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

### Required separation

Store at least:

```plaintext
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:

```plaintext
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:

- [`main.ts`, request and positional collection](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/depeg-monitor/main.ts#L121-L161)
- [`main.ts`, positional mapping](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/depeg-monitor/main.ts#L176-L195)

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

### Recommended repair

For every observation, carry and verify:

```plaintext
assetId
feedId
source domain
raw source price
sourceObservedAt
evidenceDigest

```

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

```plaintext
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:

- [`ExposureRegistry.sol`, manual Phase 1 registration](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/contracts/ExposureRegistry.sol#L2-L7)
- [`registerExposure()`](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/contracts/ExposureRegistry.sol#L28-L39)
- [`ACTION_RECORD.md`, “provably holds” claim](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/999baaa4bcc47f25c5df1475bf4c75228083d819/ACTION_RECORD.md#L56-L70)

What is currently proven is narrower:

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

```

What is not proven is:

```plaintext
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:

- [`ExposureRegistry.sol`, symbol semantics](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/contracts/ExposureRegistry.sol#L2-L39)
- [`StableGuardCREReceiver.sol`, padded address key](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/contracts/StableGuardCREReceiver.sol#L144-L205)
- [`config.production.json`](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/depeg-monitor/config.production.json)

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

### Recommended canonical identity

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

```

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

```plaintext
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:

```plaintext
price
deviationBps
signalLevel
compositeScore
marketStress

```

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

```plaintext
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:

```plaintext
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:

```plaintext
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:

```plaintext
sourceObservedAt
workflowGeneratedAt

```

Verify:

```plaintext
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

```plaintext
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:

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

```

with the comment that stablecoin prices are never negative.

Relevant code:

- [`main.ts`, absolute-value conversion](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/depeg-monitor/main.ts#L176-L195)

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

```plaintext
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:

- [`main.ts`, hardcoded Sepolia selector](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/depeg-monitor/main.ts#L64-L75)
- [`main.ts`, client construction](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/depeg-monitor/main.ts#L110-L119)

### 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:

- [`main.ts`, cooldown read](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/depeg-monitor/main.ts#L162-L200)
- [`main.ts`, full results encoded](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/8ffd1dd7845bd8d2288e3fd1b5ffe2a3535737bf/stableguard-cre/depeg-monitor/main.ts#L201-L225)

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

```plaintext
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:

```plaintext
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:

- [`ACTION_RECORD.md`, section 1.3](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/999baaa4bcc47f25c5df1475bf4c75228083d819/ACTION_RECORD.md#L102-L105)
- [`ACTION_RECORD.md`, section 3.5](https://github.com/matsblocknode1957-oss/depegguard-skill/blob/999baaa4bcc47f25c5df1475bf4c75228083d819/ACTION_RECORD.md#L235-L276)

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:

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

```

It does not prove:

```plaintext
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:

```plaintext
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

```plaintext
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.

```plaintext
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

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

```

Invariant:

```plaintext
one observation can affect one asset incident at most once

```

## 2. Root incident and event epoch identity

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

```

Invariant:

```plaintext
an epoch may expire, but causal lineage remains explicit

```

## 3. Physical hold identity

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

```

Invariant:

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

```

## 4. Delivery-attempt identity

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

```

Invariant:

```plaintext
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:

```plaintext
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:

```plaintext
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.

---

<div class="post-metadata">

**Author:** ![Matthew77](https://avatars.discourse-cdn.com/v4/letter/m/7ba0ec/32.png) [@Matthew77](https://forum.arbitrum.foundation/u/Matthew77)\
**Post date:** [August 27, 2026, 7:34am UTC](https://forum.arbitrum.foundation/t/stableguard-by-fintechcheck-real-time-stablecoin-depeg-detection-for-treasury-defi-protection/31204/11 "2026-08-27T07:34:29Z")

</div>

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

---

<div class="post-metadata">

**Author:** ![cxclrfx](https://avatars.discourse-cdn.com/v4/letter/c/eb9ed0/32.png) [@cxclrfx](https://forum.arbitrum.foundation/u/cxclrfx)\
**Post date:** [August 27, 2026, 9:35am UTC](https://forum.arbitrum.foundation/t/stableguard-by-fintechcheck-real-time-stablecoin-depeg-detection-for-treasury-defi-protection/31204/12 "2026-08-27T09:35:38Z")

</div>

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.

---

<div class="post-metadata">

**Author:** ![Matthew77](https://avatars.discourse-cdn.com/v4/letter/m/7ba0ec/32.png) [@Matthew77](https://forum.arbitrum.foundation/u/Matthew77)\
**Post date:** [October 4, 2026, 10:33am UTC](https://forum.arbitrum.foundation/t/stableguard-by-fintechcheck-real-time-stablecoin-depeg-detection-for-treasury-defi-protection/31204/13 "2026-10-04T10:33:28Z")

</div>

Update: StableGuard is now live-tested on Arbitrum Sepolia

Following on from @MconnectDAO’s pilot suggestion, here’s where StableGuard has got to.

On 2 Oct, a simulated 3% USDC depeg ran end to end on Arbitrum Sepolia through Chainlink CRE. In a single transaction, a depeg event was opened and confirmed, a protection hold was recorded and the vault was frozen.

Transaction: `sepolia.arbiscan.io/tx/0x2bce3db20f25841c7d0570440ffd22e05542842302286da790d6c9591f1cbc6c`  
2-minute demo: `youtu.be/TnBZz8lVtpM`

How this maps to the points raised earlier in the thread:

- Independent validation: 10 Chainlink nodes reach consensus on each report, and Chainlink’s KeystoneForwarder verifies their signatures before the receiver acts.
- Asset-specific evidence: a vault is only frozen if it’s registered on-chain as holding the depegging coin.
- Freshness: stale and replayed reports are rejected on-chain.
- Recovery: automatic once the price holds steady for a set window, with no manual reset.
- Override: control stays with the vault’s own governance, which can revoke StableGuard’s pause role at any time. StableGuard can only flip the freeze switch; it can never move funds.

Each vault chooses full freeze or deposit-only mode. 240 contract tests, and live testing caught and fixed a real integration gap (the forwarder’s ERC-165 check) before any funds were involved. It’s also been submitted to the Arbitrum Open House Singapore Buildathon.

I’m still keen to run the read-only pilot suggested here, where StableGuard reports what it would have done without holding any pause authority. Happy to write that up formally if there’s interest.

Code: `github.com/matsblocknode1957-oss/depegguard-skill`
