|
| 1 | +// SPDX-License-Identifier: Apache-2.0 |
| 2 | + |
| 3 | +//go:build tinygo || wasip1 |
| 4 | + |
| 5 | +package main |
| 6 | + |
| 7 | +import ( |
| 8 | + "github.com/simonovic86/igor/sdk/igor" |
| 9 | +) |
| 10 | + |
| 11 | +// stateSize is the fixed checkpoint size in bytes: |
| 12 | +// TickCount(8) + BirthNano(8) + LastNano(8) + LastProcessedSlot(8) + |
| 13 | +// PriceLatest(8) + PriceHigh(8) + PriceLow(8) + FirstWarningSlot(8) + |
| 14 | +// WarningActive(4) + SlotsProcessed(4) |
| 15 | +const stateSize = 72 |
| 16 | + |
| 17 | +// Liquidation threshold in cents. Position: 10 ETH collateral, 15,000 USDC debt. |
| 18 | +// Liquidation when collateral value ($ETH_price × 10) approaches debt. |
| 19 | +// Threshold: ETH = $1,550.00 → collateral = $15,500 (just above $15,000 debt). |
| 20 | +const liquidationThresholdCents = 155000 |
| 21 | + |
| 22 | +// LiquidationWatcher tracks a simulated ETH position against a deterministic |
| 23 | +// price curve. On resume after downtime, it detects the gap, replays missed |
| 24 | +// time slots, and discovers events that occurred while it was absent. |
| 25 | +// |
| 26 | +// The world is a pure function of time — it exists whether or not the agent |
| 27 | +// is running. The agent's job is to stay continuous with that world. |
| 28 | +type LiquidationWatcher struct { |
| 29 | + TickCount uint64 |
| 30 | + BirthNano int64 |
| 31 | + LastNano int64 |
| 32 | + LastProcessedSlot uint64 |
| 33 | + PriceLatest uint64 // cents (e.g., 200000 = $2,000.00) |
| 34 | + PriceHigh uint64 |
| 35 | + PriceLow uint64 |
| 36 | + FirstWarningSlot uint64 // first slot threshold breached (0 = never) |
| 37 | + WarningActive uint32 // 1 = currently below threshold |
| 38 | + SlotsProcessed uint32 |
| 39 | +} |
| 40 | + |
| 41 | +// Price curve control points: slot → price in cents. |
| 42 | +// Authored for narrative tension, not randomness. |
| 43 | +// |
| 44 | +// 0–15: Calm with mild volatility. Agent is alive. |
| 45 | +// 16–30: Increasing pressure. Agent will die around slot 15. |
| 46 | +// 31–45: Sharp drop, threshold breach at slot 38, partial recovery. |
| 47 | +// Agent is dead for all of this. |
| 48 | +// 46–60: Recovery and stabilization. Agent resumes here. |
| 49 | +type controlPoint struct { |
| 50 | + slot uint64 |
| 51 | + price uint64 // cents |
| 52 | +} |
| 53 | + |
| 54 | +var curve = []controlPoint{ |
| 55 | + {0, 200000}, // $2,000.00 — opening |
| 56 | + {5, 205000}, // $2,050.00 — mild upward drift |
| 57 | + {10, 192000}, // $1,920.00 — first dip |
| 58 | + {15, 198000}, // $1,980.00 — partial recovery |
| 59 | + {20, 185000}, // $1,850.00 — renewed pressure |
| 60 | + {25, 178000}, // $1,780.00 — drawdown deepens |
| 61 | + {30, 182000}, // $1,820.00 — small bounce (last checkpoint before crash) |
| 62 | + {35, 165000}, // $1,650.00 — sharp drop during outage |
| 63 | + {38, 154000}, // $1,540.00 — THRESHOLD BREACHED during outage |
| 64 | + {40, 158000}, // $1,580.00 — partial recovery, still danger zone |
| 65 | + {45, 162000}, // $1,620.00 — further recovery |
| 66 | + {50, 170000}, // $1,700.00 — stabilizing |
| 67 | + {55, 175000}, // $1,750.00 — continued recovery |
| 68 | +} |
| 69 | + |
| 70 | +// priceAtSlot returns the ETH price in cents for a given time slot. |
| 71 | +// Linear interpolation between control points; hold last value beyond curve. |
| 72 | +func priceAtSlot(slot uint64) uint64 { |
| 73 | + if slot <= curve[0].slot { |
| 74 | + return curve[0].price |
| 75 | + } |
| 76 | + for i := 1; i < len(curve); i++ { |
| 77 | + if slot <= curve[i].slot { |
| 78 | + prev := curve[i-1] |
| 79 | + next := curve[i] |
| 80 | + span := next.slot - prev.slot |
| 81 | + offset := slot - prev.slot |
| 82 | + if next.price >= prev.price { |
| 83 | + return prev.price + (next.price-prev.price)*offset/span |
| 84 | + } |
| 85 | + return prev.price - (prev.price-next.price)*offset/span |
| 86 | + } |
| 87 | + } |
| 88 | + // Beyond last control point: hold last price. |
| 89 | + return curve[len(curve)-1].price |
| 90 | +} |
| 91 | + |
| 92 | +func (w *LiquidationWatcher) Init() {} |
| 93 | + |
| 94 | +func (w *LiquidationWatcher) Tick() bool { |
| 95 | + w.TickCount++ |
| 96 | + |
| 97 | + now := igor.ClockNow() |
| 98 | + if w.BirthNano == 0 { |
| 99 | + w.BirthNano = now |
| 100 | + } |
| 101 | + w.LastNano = now |
| 102 | + |
| 103 | + currentSlot := uint64((now - w.BirthNano) / 1_000_000_000) |
| 104 | + |
| 105 | + // First tick ever: initialize and process slot 0. |
| 106 | + if w.TickCount == 1 { |
| 107 | + w.processSlot(0, "live") |
| 108 | + w.LastProcessedSlot = 0 |
| 109 | + return false |
| 110 | + } |
| 111 | + |
| 112 | + // Detect gap. |
| 113 | + if currentSlot > w.LastProcessedSlot+1 { |
| 114 | + gap := currentSlot - w.LastProcessedSlot - 1 |
| 115 | + igor.Logf("[resume] restored checkpoint from slot %d", w.LastProcessedSlot) |
| 116 | + igor.Logf("[resume] current slot is %d", currentSlot) |
| 117 | + igor.Logf("[resume] gap detected: %d missed slots (%d..%d)", |
| 118 | + gap, w.LastProcessedSlot+1, currentSlot-1) |
| 119 | + |
| 120 | + // Remember whether warning was already known before catch-up. |
| 121 | + warningBefore := w.FirstWarningSlot |
| 122 | + |
| 123 | + // Catch up on missed slots. |
| 124 | + for slot := w.LastProcessedSlot + 1; slot < currentSlot; slot++ { |
| 125 | + w.processSlot(slot, "catch-up") |
| 126 | + } |
| 127 | + |
| 128 | + // Summary after catch-up. |
| 129 | + if w.FirstWarningSlot != 0 && warningBefore == 0 { |
| 130 | + igor.Logf("[catch-up] ⚠ WARNING first occurred at slot %d (during downtime)", w.FirstWarningSlot) |
| 131 | + } |
| 132 | + } |
| 133 | + |
| 134 | + // Process current slot as live. |
| 135 | + w.processSlot(currentSlot, "live") |
| 136 | + w.LastProcessedSlot = currentSlot |
| 137 | + |
| 138 | + return false |
| 139 | +} |
| 140 | + |
| 141 | +func (w *LiquidationWatcher) processSlot(slot uint64, mode string) { |
| 142 | + price := priceAtSlot(slot) |
| 143 | + |
| 144 | + w.PriceLatest = price |
| 145 | + if w.PriceHigh == 0 || price > w.PriceHigh { |
| 146 | + w.PriceHigh = price |
| 147 | + } |
| 148 | + if w.PriceLow == 0 || price < w.PriceLow { |
| 149 | + w.PriceLow = price |
| 150 | + } |
| 151 | + w.SlotsProcessed++ |
| 152 | + |
| 153 | + belowThreshold := price < liquidationThresholdCents |
| 154 | + |
| 155 | + if belowThreshold && w.FirstWarningSlot == 0 { |
| 156 | + w.FirstWarningSlot = slot |
| 157 | + } |
| 158 | + if belowThreshold { |
| 159 | + w.WarningActive = 1 |
| 160 | + } else { |
| 161 | + w.WarningActive = 0 |
| 162 | + } |
| 163 | + |
| 164 | + // Compute distance to threshold. |
| 165 | + var distSign string |
| 166 | + var dist uint64 |
| 167 | + if price >= liquidationThresholdCents { |
| 168 | + dist = price - liquidationThresholdCents |
| 169 | + distSign = "+" |
| 170 | + } else { |
| 171 | + dist = liquidationThresholdCents - price |
| 172 | + distSign = "-" |
| 173 | + } |
| 174 | + |
| 175 | + if belowThreshold { |
| 176 | + if slot == w.FirstWarningSlot { |
| 177 | + igor.Logf("[%s] slot %d: ETH $%d.%02d threshold $%d.%02d distance %s$%d.%02d ⚠ LIQUIDATION WARNING (threshold breached)", |
| 178 | + mode, slot, |
| 179 | + price/100, price%100, |
| 180 | + liquidationThresholdCents/100, liquidationThresholdCents%100, |
| 181 | + distSign, dist/100, dist%100) |
| 182 | + } else { |
| 183 | + igor.Logf("[%s] slot %d: ETH $%d.%02d threshold $%d.%02d distance %s$%d.%02d ⚠ WARNING", |
| 184 | + mode, slot, |
| 185 | + price/100, price%100, |
| 186 | + liquidationThresholdCents/100, liquidationThresholdCents%100, |
| 187 | + distSign, dist/100, dist%100) |
| 188 | + } |
| 189 | + } else { |
| 190 | + igor.Logf("[%s] slot %d: ETH $%d.%02d threshold $%d.%02d distance %s$%d.%02d", |
| 191 | + mode, slot, |
| 192 | + price/100, price%100, |
| 193 | + liquidationThresholdCents/100, liquidationThresholdCents%100, |
| 194 | + distSign, dist/100, dist%100) |
| 195 | + } |
| 196 | +} |
| 197 | + |
| 198 | +func (w *LiquidationWatcher) Marshal() []byte { |
| 199 | + return igor.NewEncoder(stateSize). |
| 200 | + Uint64(w.TickCount). |
| 201 | + Int64(w.BirthNano). |
| 202 | + Int64(w.LastNano). |
| 203 | + Uint64(w.LastProcessedSlot). |
| 204 | + Uint64(w.PriceLatest). |
| 205 | + Uint64(w.PriceHigh). |
| 206 | + Uint64(w.PriceLow). |
| 207 | + Uint64(w.FirstWarningSlot). |
| 208 | + Uint32(w.WarningActive). |
| 209 | + Uint32(w.SlotsProcessed). |
| 210 | + Finish() |
| 211 | +} |
| 212 | + |
| 213 | +func (w *LiquidationWatcher) Unmarshal(data []byte) { |
| 214 | + d := igor.NewDecoder(data) |
| 215 | + w.TickCount = d.Uint64() |
| 216 | + w.BirthNano = d.Int64() |
| 217 | + w.LastNano = d.Int64() |
| 218 | + w.LastProcessedSlot = d.Uint64() |
| 219 | + w.PriceLatest = d.Uint64() |
| 220 | + w.PriceHigh = d.Uint64() |
| 221 | + w.PriceLow = d.Uint64() |
| 222 | + w.FirstWarningSlot = d.Uint64() |
| 223 | + w.WarningActive = d.Uint32() |
| 224 | + w.SlotsProcessed = d.Uint32() |
| 225 | + if err := d.Err(); err != nil { |
| 226 | + panic("unmarshal checkpoint: " + err.Error()) |
| 227 | + } |
| 228 | +} |
| 229 | + |
| 230 | +func init() { igor.Run(&LiquidationWatcher{}) } |
| 231 | +func main() {} |
0 commit comments