|
| 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. |
0 commit comments