Skip to content

Commit 7ebe02e

Browse files
Merge branch 'master' into fee-processor-impl
2 parents 28b3f0f + 6f8e83f commit 7ebe02e

14 files changed

Lines changed: 851 additions & 2 deletions

File tree

CLAUDE.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ directory.
2020
Available shared skills:
2121
- `hydration_cl0wdit` - security audit workflow for Substrate runtime and pallet
2222
code.
23+
- `circuit-breaker-incident` - investigate snakewatch lockdown alerts (XCM
24+
deposit fuse, trade/liquidity limits).
25+
26+
## Operator runbooks & docs
27+
28+
- `scripts/mint-limit/README.md` — generate TC proposals to set XCM mint limits and lift circuit breaker lockdowns
29+
- `scripts/dca-monitor/README.md` — verify DCA fixes on a Chopsticks fork before deploying a runtime upgrade
30+
- `scripts/onchain-routes/README.md` — generate TC proposals to register/update on-chain router routes
31+
- `integration-tests/README.md` — debug prod issues via scraper snapshots + integration tests
2332

2433
## Project overview
2534

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
---
2+
name: circuit-breaker-incident
3+
description: Investigate Hydration circuit breaker triggers. Use when snakewatch reports an asset lockdown, circuit breaker alert, or when asked to analyze why an asset was locked on Hydration chain. Covers XCM deposit fuse (issuance increase), trade volume limits, and liquidity limits.
4+
---
5+
6+
# Circuit Breaker Incident Response
7+
8+
## Scripts
9+
10+
All scripts are in `scripts/` and use ESM (`import`). Run with `node <script>`.
11+
12+
| Script | Purpose | Usage |
13+
|---|---|---|
14+
| `query-lockdown.cjs` | Check lockdown state + look up XCM relay block | `node query-lockdown.cjs <ASSET_ID> [TRIGGER_BLOCK]` (run from `hydration-node/scripts/mint-limit/`) |
15+
| `get-trigger-events.cjs` | Dump `tokens.Deposited` / `Reserved` / `AssetLockdown` / `messageQueue.Processed` events around the trigger block | `node get-trigger-events.cjs <ASSET_ID> <TRIGGER_BLOCK> [WINDOW=2]` (run from `hydration-node/scripts/mint-limit/`) |
16+
| `scripts/mint-limit/get-spot-price.js` | USD spot price via Hydration SDK | `node scripts/mint-limit/get-spot-price.js <ASSET_ID>` (lives in `scripts/mint-limit/` because it needs the SDK from its `node_modules`) |
17+
| `scan-deposits.js` | Scan all deposits in lookback period | `node scan-deposits.js <ASSET_ID> <TRIGGER_BLOCK> [PERIOD=14400] [BATCH_SIZE=50]` (run from `hydration-node/scripts/mint-limit/`) |
18+
| `generate-tc-unlock.js` | Generate TC proposal hex to lift lockdown + raise limit | `node generate-tc-unlock.js <ASSET_ID> <NEW_LIMIT_HUMAN> [TC_THRESHOLD=4]` (run from `hydration-node/scripts/mint-limit/`) |
19+
20+
**Note**: `.cjs` scripts use CommonJS (`require`); `.js` scripts use ESM (`import`). All scripts depend on `@polkadot/api` (and `get-spot-price.js` also on `@galacticcouncil/sdk`), which live in `hydration-node/scripts/mint-limit/node_modules` — so run each script from that directory.
21+
22+
## Quick Response Workflow
23+
24+
When a circuit breaker alert comes in (e.g. from snakewatch):
25+
26+
1. **Extract from alert**: asset name, asset ID, locked-until block
27+
2. **Find the trigger block and event** via Subscan API
28+
3. **Get asset details** from chain (decimals, xcm_rate_limit)
29+
4. **Calculate amounts** in human-readable units and USD
30+
5. **Trace the XCM origin** if deposit-triggered
31+
6. **Report findings** with Subscan links
32+
33+
## Known Gotchas
34+
35+
- **ESM vs CommonJS**: `scripts/mint-limit/` has `"type": "module"` in package.json (and so does the repo's parent dir). Scripts using `require()` must be saved as `.cjs` (e.g. `query-lockdown.cjs`). Scripts using `import` (e.g. `get-spot-price.js`, `scan-deposits.js`) work as `.js`.
36+
- **Subscan API key required**: All API endpoints return 403 without `X-API-Key` header. Use chain-direct queries as fallback.
37+
- **Spot price script can fail silently**: `get-spot-price.js` may fail for assets without good liquidity routes. Always check exit code and fall back to the omnipool state query in `references/price-from-omnipool.md`.
38+
39+
## Step 1: Find the Lockdown Event
40+
41+
Query Subscan for the most recent `AssetLockdown` event:
42+
43+
```bash
44+
curl -s -X POST 'https://hydration.api.subscan.io/api/v2/scan/events' \
45+
-H 'Content-Type: application/json' \
46+
-d '{"module":"circuitbreaker","event_id":"AssetLockdown","page":0,"row":5}'
47+
```
48+
49+
Then get event params (asset_id, until block):
50+
51+
```bash
52+
curl -s -X POST 'https://hydration.api.subscan.io/api/scan/event' \
53+
-H 'Content-Type: application/json' \
54+
-d '{"event_index":"<BLOCK>-<EVENT_IDX>"}'
55+
```
56+
57+
## Step 2: Get All Events in Trigger Block
58+
59+
Preferred (chain-direct, no API key needed):
60+
61+
```bash
62+
cd hydration-node/scripts/mint-limit
63+
node ../../ai_skills/circuit-breaker-incident/scripts/get-trigger-events.cjs <ASSET_ID> <TRIGGER_BLOCK>
64+
```
65+
66+
Look for the sequence: `messageQueue.Processed``tokens.Deposited``circuitBreaker.AssetLockdown`. The `messageQueue.Processed` event has the XCM origin (e.g. `{"sibling":2004}` = Moonbeam, `{"sibling":1000}` = Asset Hub Polkadot). The `tokens.Reserved` amount equals the over-limit excess.
67+
68+
Subscan fallback (requires `SUBSCAN_API_KEY`):
69+
70+
```bash
71+
curl -s -X POST 'https://hydration.api.subscan.io/api/v2/scan/events' \
72+
-H 'Content-Type: application/json' \
73+
-H "X-API-Key: $SUBSCAN_API_KEY" \
74+
-d '{"block_num":<BLOCK>,"page":0,"row":100}'
75+
```
76+
77+
## Step 3: Query Asset Details from Chain
78+
79+
Use `@polkadot/api` (installed globally):
80+
81+
```javascript
82+
NODE_PATH=$(npm root -g) node -e "
83+
const { ApiPromise, WsProvider } = require('@polkadot/api');
84+
async function main() {
85+
const api = await ApiPromise.create({ provider: new WsProvider('wss://rpc.hydradx.cloud'), noInitWarn: true });
86+
const asset = await api.query.assetRegistry.assets(ASSET_ID);
87+
console.log(JSON.stringify(asset.toHuman(), null, 2));
88+
const lockdown = await api.query.circuitBreaker.assetLockdownState(ASSET_ID);
89+
console.log('Lockdown:', JSON.stringify(lockdown.toHuman(), null, 2));
90+
await api.disconnect();
91+
}
92+
main();
93+
"
94+
```
95+
96+
Key fields from asset registry:
97+
- `decimals` — for converting raw amounts
98+
- `xcmRateLimit` — the deposit limit that triggers lockdown (issuance fuse)
99+
- `symbol` — human-readable name
100+
101+
## Step 4: Calculate Amounts
102+
103+
```python
104+
deposit_raw = <from tokens.Deposited event>
105+
limit_raw = <xcmRateLimit from registry>
106+
decimals = <from registry>
107+
108+
deposit = deposit_raw / 10**decimals
109+
limit = limit_raw / 10**decimals
110+
excess = deposit - limit
111+
```
112+
113+
For USD value, use the **Hydration SDK spot price** (preferred — on-chain, accurate):
114+
115+
```bash
116+
cd hydration-node/scripts/mint-limit && node get-spot-price.js <ASSET_ID> 2>/dev/null
117+
```
118+
119+
This calls `sdk.api.router.getBestSpotPrice(assetId, '10')` where `'10'` is USDT.
120+
Returns JSON: `{"assetId":"16","symbol":"GLMR","decimals":18,"usdPrice":0.0147}`
121+
122+
**Note**: The script lives in `hydration-node/scripts/mint-limit/` (needs its `node_modules` with `@galacticcouncil/sdk`). Use `2>/dev/null` to suppress noisy polkadot disconnect logs.
123+
124+
**Fallback**: If `get-spot-price.js` fails (e.g. EURC has no good route), query omnipool state directly — see `references/price-from-omnipool.md`.
125+
126+
## Step 5: Find the XCM Message Link
127+
128+
`query-lockdown.cjs <ASSET_ID> <TRIGGER_BLOCK>` already prints the Subscan XCM search link — no extra steps needed in the normal case. This section just explains how that link is built and what to do if it fails.
129+
130+
### How the link is built
131+
132+
The script reads `parachainSystem.hrmpWatermark` at the trigger block and at the prior block:
133+
134+
- `hrmpWatermark` is the relay block up to which HRMP messages have been consumed.
135+
- The triggering XCM was sent at a relay block in `(prevWatermark, triggerWatermark]` — the "tight" window.
136+
- The Subscan link widens this by **±10 relay blocks** because Subscan's XCM filter is fuzzy on the relay-block dimension (and the message's `sentAt` can sit slightly outside the watermark advance).
137+
138+
### Important notes
139+
- Subscan XCM message API (`api/scan/xcm/messages`) is **paywalled (402)**. Use the UI link.
140+
- Subscan UI has **Cloudflare protection**`web_fetch`/`curl` won't work; give the link to the user.
141+
- The `messageQueue.Processed` event's `id` hash is **NOT searchable** on Subscan; identify the message by matching the deposit amount + recipient against the XCM trace.
142+
- If the ±10 window returns nothing, widen further (±25, ±50). Subscan indexing may also lag for very recent blocks.
143+
- The link may return multiple XCM messages in the window — pick the one whose amount/recipient matches the `tokens.Deposited` event from Step 2.
144+
145+
## Step 6: Report Template
146+
147+
```
148+
Circuit breaker triggered for <SYMBOL> (asset <ID>).
149+
<AMOUNT> <SYMBOL> (~$<USD>) deposited via XCM from <ORIGIN_CHAIN>.
150+
Mint limit: <LIMIT> <SYMBOL> (~$<USD>). Excess: <EXCESS> (~$<USD>).
151+
Asset locked until block <BLOCK> (~<HOURS>h).
152+
153+
XCM message search: <SUBSCAN_XCM_SEARCH_LINK>
154+
Block events: https://hydration.subscan.io/block/<TRIGGER_BLOCK>?tab=event
155+
```
156+
157+
## Circuit Breaker Types
158+
159+
Three fuse types can trigger lockdown:
160+
161+
| Fuse | What triggers it | Key storage |
162+
|---|---|---|
163+
| **Issuance (deposit) fuse** | XCM deposit exceeds `xcmRateLimit` per period | `AssetLockdownState` |
164+
| **Trade volume limit** | Net trade volume exceeds % of pool reserve per block | `TradeVolumeLimitPerAsset` |
165+
| **Liquidity limit** | Add/remove liquidity exceeds % limit per block | `LiquidityAddLimitPerAsset` / `LiquidityRemoveLimitPerAsset` |
166+
167+
Most common trigger: **issuance fuse** from large XCM bridge transfers.
168+
169+
**Two trigger patterns:**
170+
1. **Single large deposit** — one XCM deposit exceeds the limit (e.g. GLMR 6.9M > 4.3M limit)
171+
2. **Cumulative period breach** — multiple small deposits over the period cumulatively exceed the limit. The triggering deposit may be tiny (e.g. jitoSOL: 38 jitoSOL triggered it but period total exceeded 2,777 limit). Check `tokens.Reserved` amount vs `tokens.Deposited` to distinguish.
172+
173+
If `tokens.Deposited` amount < `xcmRateLimit`, it's a cumulative trigger.
174+
175+
## Quick Trigger Block Calculation
176+
177+
`trigger_block ≈ locked_until_block - 14400` (default lockdown period is 14400 blocks)
178+
179+
Verify by matching against Subscan's `AssetLockdown` events list.
180+
181+
## Key Parachain IDs
182+
183+
| Chain | Para ID |
184+
|---|---|
185+
| Hydration | 2034 |
186+
| Asset Hub | 1000 |
187+
| Moonbeam | 2004 |
188+
| Astar | 2006 |
189+
| Acala | 2000 |
190+
| Interlay | 2032 |
191+
| Bifrost | 2030 |
192+
| Centrifuge | 2031 |
193+
194+
## Subscan API Notes
195+
196+
**⚠️ All Subscan API endpoints now require an API key (HTTP 403 without one).** Store in `SUBSCAN_API_KEY` env var and pass as `-H "X-API-Key: $SUBSCAN_API_KEY"`. If unavailable, use chain-direct queries as fallback (see "Chain-Direct Fallback" section below).
197+
198+
- Events API: `https://hydration.api.subscan.io/api/v2/scan/events` — free
199+
- Event detail: `https://hydration.api.subscan.io/api/scan/event` — free
200+
- Extrinsic detail: `https://hydration.api.subscan.io/api/scan/extrinsic` — free
201+
- XCM messages: `https://hydration.api.subscan.io/api/scan/xcm/messages`**paywalled (402)**
202+
- Subscan UI has Cloudflare protection — `web_fetch` won't work, provide links to user instead
203+
204+
## Chain-Direct Fallback: Scanning for Deposits
205+
206+
When Subscan API is unavailable, or to analyze **cumulative triggers** (where you need to find all deposits in the 14,400-block period), scan chain events directly:
207+
208+
```javascript
209+
// Batch-scan pattern: query 50 blocks in parallel for tokens.Deposited events
210+
import { ApiPromise, WsProvider } from '@polkadot/api';
211+
212+
const TRIGGER_BLOCK = <trigger_block>;
213+
const PERIOD = 14400;
214+
const START_BLOCK = TRIGGER_BLOCK - PERIOD;
215+
const ASSET_ID = '<asset_id>';
216+
const BATCH_SIZE = 50;
217+
218+
const api = await ApiPromise.create({ provider: new WsProvider('wss://rpc.hydradx.cloud'), noInitWarn: true });
219+
220+
const deposits = [];
221+
for (let batchStart = START_BLOCK; batchStart < TRIGGER_BLOCK; batchStart += BATCH_SIZE) {
222+
const batchEnd = Math.min(batchStart + BATCH_SIZE, TRIGGER_BLOCK + 1);
223+
const blockNums = [];
224+
for (let b = batchStart; b < batchEnd; b++) blockNums.push(b);
225+
226+
const hashes = await Promise.all(blockNums.map(b => api.rpc.chain.getBlockHash(b)));
227+
const eventsArr = await Promise.all(hashes.map(h => api.query.system.events.at(h)));
228+
229+
for (let i = 0; i < blockNums.length; i++) {
230+
for (const record of eventsArr[i]) {
231+
const { event } = record;
232+
if (event.section === 'tokens' && event.method === 'Deposited' && event.data[0].toString() === ASSET_ID) {
233+
deposits.push({
234+
block: blockNums[i],
235+
who: event.data[1].toString(),
236+
amount: BigInt(event.data[2].toString())
237+
});
238+
}
239+
}
240+
}
241+
}
242+
```
243+
244+
This takes ~3-5 minutes for the full 14,400-block window. Group results by recipient to identify the main depositors.
245+
246+
## Past Incidents (Reference)
247+
248+
### GLMR (16) — Block 11375067 (Feb 2026)
249+
- Single large deposit: 6,900,001 GLMR (~$101K) from Moonbeam (para 2004)
250+
- Limit: 4,295,059 GLMR. Excess: 2,604,942 GLMR
251+
- Relay block: 29959078
252+
253+
### jitoSOL (40) — Block 10954824
254+
- Cumulative trigger: 38.31 jitoSOL deposit was the straw, period total exceeded 2,777 limit
255+
- Origin: Moonbeam (para 2004). Relay block: 29525807
256+
257+
### CFG (41) — Block 10536839
258+
- Single large deposit: 1,168,803 CFG (~$104K) from Asset Hub (para 1000)
259+
- Limit: 725,000 CFG. Excess: 443,803 CFG
260+
- Relay block: 29100043
261+
262+
### EURC (44) — Block 11786380 (Mar 2026)
263+
- Cumulative trigger: 23 deposits totaling 221,234 EURC over period, limit was 200,000 EURC
264+
- Triggering deposit: 34,751 EURC (~$39,964). All deposits from Moonbeam (para 2004)
265+
- Three main recipients accounted for 98% of volume (~$250K total)
266+
- Relay block: 30429186. Limit raised to 800,000 EURC via TC proposal
267+
268+
## Lifting Lockdown
269+
270+
If the deposit is legitimate and lockdown needs lifting early, a Technical Committee proposal is required.
271+
272+
In most cases, you'll want to **batch two calls** in a single TC proposal:
273+
1. `circuitBreaker.forceLiftLockdown(assetId)` — immediately lifts the lockdown
274+
2. `assetRegistry.update(assetId, ..., xcmRateLimit)` — raises the mint limit to prevent re-trigger
275+
276+
Pattern:
277+
```javascript
278+
// 1. Force lift lockdown
279+
const forceLiftCall = api.tx.circuitBreaker.forceLiftLockdown(ASSET_ID);
280+
281+
// 2. Update xcmRateLimit (e.g. 800k EURC = 800_000 * 10^decimals)
282+
const updateCall = api.tx.assetRegistry.update(
283+
ASSET_ID,
284+
null, null, null, // name, asset_type, existential_deposit
285+
NEW_LIMIT.toString(), // xcm_rate_limit
286+
null, null, null, null // is_sufficient, symbol, decimals, location
287+
);
288+
289+
// 3. Batch and wrap in TC propose
290+
const batch = api.tx.utility.batchAll([forceLiftCall, updateCall]);
291+
const lengthBound = batch.method.encodedLength ?? batch.method.toU8a().length;
292+
const tcProposal = api.tx.technicalCommittee.propose(TC_THRESHOLD, batch.method, lengthBound);
293+
294+
console.log('HEX:', tcProposal.method.toHex());
295+
```
296+
297+
See `scripts/mint-limit/eurc-lockdown-proposal.js` as a complete template, or `scripts/mint-limit/liftLockdown.js` for lift-only proposals.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Price from Omnipool State
2+
3+
Query omnipool asset state for two assets and compute the cross-price via hub reserve ratios.
4+
5+
```javascript
6+
NODE_PATH=$(npm root -g) node -e "
7+
const { ApiPromise, WsProvider } = require('@polkadot/api');
8+
async function main() {
9+
const api = await ApiPromise.create({ provider: new WsProvider('wss://rpc.hydradx.cloud'), noInitWarn: true });
10+
11+
const ASSET_ID = 16; // target asset
12+
const USDT_ID = 10; // quote asset (USDT, 6 decimals)
13+
14+
const targetState = (await api.query.omnipool.assets(ASSET_ID)).toJSON();
15+
const usdtState = (await api.query.omnipool.assets(USDT_ID)).toJSON();
16+
17+
if (targetState && usdtState && targetState.hubReserve && usdtState.hubReserve) {
18+
const hubPerTargetUnit = Number(BigInt(targetState.hubReserve)) / Number(BigInt(targetState.reserve));
19+
const hubPerUsdtUnit = Number(BigInt(usdtState.hubReserve)) / Number(BigInt(usdtState.reserve));
20+
// Adjust for decimal difference: target has TARGET_DECIMALS, USDT has 6
21+
const price = (hubPerTargetUnit * 10**TARGET_DECIMALS) / (hubPerUsdtUnit * 10**6);
22+
console.log('Price in USD:', price);
23+
} else {
24+
console.log('Asset not in omnipool or state null — try a different quote asset');
25+
}
26+
await api.disconnect();
27+
}
28+
main();
29+
"
30+
```
31+
32+
**Note**: If USDT (asset 10) returns null for either side, try DOT (asset 5) as the intermediate / quote asset and convert via DOT/USDT.

0 commit comments

Comments
 (0)