Skip to content

Commit 237af6b

Browse files
simonovic86claude
andauthored
feat(agent): add gap-aware liquidation watcher demo (#30) (#30)
Adds a liquidation risk watcher agent that proves continuity through missing time — not just checkpoint/resume, but gap detection, catch-up replay of missed time slots, and retroactive event discovery. The agent monitors a simulated ETH position against a deterministic price curve (pure function of discrete time slots). On resume after downtime, it detects the gap, replays missed slots with [catch-up] prefix, discovers that the liquidation threshold was breached during the outage, and seamlessly transitions to [live] processing. Demo: make demo-liquidation (~55 seconds) Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fe24779 commit 237af6b

7 files changed

Lines changed: 424 additions & 1 deletion

File tree

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ make agent-pricewatcher # Build price watcher WASM agent → agents/pricewatcher
2121
make agent-sentinel # Build treasury sentinel WASM agent → agents/sentinel/agent.wasm
2222
make agent-x402buyer # Build x402 buyer WASM agent → agents/x402buyer/agent.wasm
2323
make agent-deployer # Build deployer WASM agent → agents/deployer/agent.wasm
24+
make agent-liquidation # Build liquidation watcher WASM agent → agents/liquidation/agent.wasm
2425
make test # Run tests: go test -v ./...
2526
make lint # golangci-lint (5m timeout)
2627
make vet # go vet
@@ -33,6 +34,7 @@ make demo-pricewatcher # Build + run price watcher demo (fetch prices → stop
3334
make demo-sentinel # Build + run treasury sentinel demo (effect lifecycle → crash → reconcile)
3435
make demo-x402 # Build + run x402 payment demo (pay for premium data → crash → reconcile)
3536
make demo-deployer # Build + run deployer demo (pay → deploy → monitor → crash → reconcile)
37+
make demo-liquidation # Build + run liquidation watcher demo (gap-aware continuity proof)
3638
make clean # Remove bin/, checkpoints/, agent.wasm
3739
```
3840

@@ -92,6 +94,7 @@ Atomic writes via temp file → fsync → rename. Every checkpoint is also archi
9294
- `agents/sentinel/` — Treasury sentinel: monitors simulated treasury balance, triggers refills with effect-safe intent tracking, demonstrates crash recovery and reconciliation
9395
- `agents/x402buyer/` — x402 payment demo: encounters HTTP 402 paywall, pays from budget via wallet_pay hostcall, receives premium data, crash-safe payment reconciliation
9496
- `agents/deployer/` — Deployer demo: pays compute provider, deploys itself, monitors deployment status, multi-step effect-safe crash recovery
97+
- `agents/liquidation/` — Liquidation risk watcher: gap-aware continuity demo with deterministic price curve, detects and replays missed time slots on resume
9598
- `agents/research/example/` — Original demo agent (Survivor) from research phases
9699
- `agents/research/reconciliation/` — Bridge reconciliation demo agent (research phase)
97100
- `scripts/demo-portable.sh` — End-to-end portable agent demo

Makefile

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: help bootstrap build build-lab clean test lint vet fmt fmt-check tidy agent agent-heartbeat agent-reconciliation agent-pricewatcher agent-sentinel agent-x402buyer agent-deployer run-agent demo demo-portable demo-pricewatcher demo-sentinel demo-x402 demo-deployer gh-check gh-metadata gh-release
1+
.PHONY: help bootstrap build build-lab clean test lint vet fmt fmt-check tidy agent agent-heartbeat agent-reconciliation agent-pricewatcher agent-sentinel agent-x402buyer agent-deployer agent-liquidation run-agent demo demo-portable demo-pricewatcher demo-sentinel demo-x402 demo-deployer demo-liquidation gh-check gh-metadata gh-release
22

33
.DEFAULT_GOAL := help
44

@@ -12,6 +12,7 @@ PRICEWATCHER_AGENT_DIR := agents/pricewatcher
1212
SENTINEL_AGENT_DIR := agents/sentinel
1313
X402BUYER_AGENT_DIR := agents/x402buyer
1414
DEPLOYER_AGENT_DIR := agents/deployer
15+
LIQUIDATION_AGENT_DIR := agents/liquidation
1516

1617
# Go commands
1718
GOCMD := go
@@ -62,6 +63,7 @@ clean: ## Remove build artifacts
6263
rm -f agents/sentinel/agent.wasm
6364
rm -f agents/x402buyer/agent.wasm
6465
rm -f agents/deployer/agent.wasm
66+
rm -f agents/liquidation/agent.wasm
6567
@echo "Clean complete"
6668

6769
test: ## Run tests (with race detector)
@@ -154,6 +156,13 @@ agent-deployer: ## Build deployer demo agent WASM
154156
cd $(DEPLOYER_AGENT_DIR) && $(MAKE) build
155157
@echo "Agent built: $(DEPLOYER_AGENT_DIR)/agent.wasm"
156158

159+
agent-liquidation: ## Build liquidation watcher demo agent WASM
160+
@echo "Building liquidation watcher agent..."
161+
@which tinygo > /dev/null || \
162+
(echo "tinygo not found. See docs/governance/DEVELOPMENT.md for installation" && exit 1)
163+
cd $(LIQUIDATION_AGENT_DIR) && $(MAKE) build
164+
@echo "Agent built: $(LIQUIDATION_AGENT_DIR)/agent.wasm"
165+
157166
demo: build agent-reconciliation ## Build and run reconciliation demo
158167
@echo "Building demo runner..."
159168
@mkdir -p $(BINARY_DIR)
@@ -192,6 +201,11 @@ demo-deployer: build agent-deployer ## Run the deployer demo (pay, deploy, monit
192201
@chmod +x scripts/demo-deployer.sh
193202
@./scripts/demo-deployer.sh
194203

204+
demo-liquidation: build agent-liquidation ## Run the liquidation watcher demo (gap-aware continuity proof)
205+
@echo "Running Liquidation Watcher Demo..."
206+
@chmod +x scripts/demo-liquidation.sh
207+
@./scripts/demo-liquidation.sh
208+
195209
check: fmt-check vet lint test ## Run all checks (formatting, vet, lint, tests)
196210
@echo "All checks passed"
197211

agents/liquidation/Makefile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.PHONY: build clean
2+
3+
build:
4+
tinygo build -target=wasi -no-debug -o agent.wasm .
5+
6+
clean:
7+
rm -f agent.wasm
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"capabilities": {
3+
"clock": { "version": 1 },
4+
"log": { "version": 1 }
5+
},
6+
"resource_limits": {
7+
"max_memory_bytes": 67108864
8+
}
9+
}

agents/liquidation/go.mod

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
module github.com/simonovic86/igor/agents/liquidation
2+
3+
go 1.24
4+
5+
require github.com/simonovic86/igor/sdk/igor v0.0.0
6+
7+
replace github.com/simonovic86/igor/sdk/igor => ../../sdk/igor

agents/liquidation/main.go

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
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

Comments
 (0)