This repository contains two Python scripts that demonstrate a structural failure mode in RWA liquidation bots: executing against closed or halted markets due to broken session logic.
The upcoming trigger: March 8, 2026 — US clocks spring forward. Any bot using hardcoded UTC offsets for NYSE/NASDAQ session hours will be wrong for every trading session afterwards.
The recurring risk: circuit breakers — NYSE Level 1/2/3 halts, LULD pauses, regulatory suspensions. These happen without warning. The same session-logic weakness that breaks on DST breaks during any unscheduled halt.
Most RWA liquidation bots check market hours like this:
EST_OFFSET = timedelta(hours=-5) # Hardcoded for EST
def is_nyse_open(utc_time):
et = utc_time + EST_OFFSET
market_open = et.replace(hour=9, minute=30)
market_close = et.replace(hour=16, minute=0)
return market_open <= et < market_closeThis breaks after DST because the UTC-to-ET offset changes from -5 (EST) to -4 (EDT). On Monday March 9 (first trading day after transition), every time-based decision is wrong by one hour:
| UTC Time | Actual ET (EDT) | Bot's Calculation (EST) | Bot Correct? |
|---|---|---|---|
| 13:30 UTC | 9:30 AM EDT — Market OPENS | 8:30 AM EST — "Closed" | NO |
| 20:00 UTC | 4:00 PM EDT — Market CLOSES | 3:00 PM EST — "Open" | NO |
| 20:30 UTC | 4:30 PM EDT — Closed 30 min | 3:30 PM EST — "Open" | NO — dangerous |
| 21:00 UTC | 5:00 PM EDT — Closed 1 hour | 4:00 PM EST — "Just closed" | NO |
The same bot that ignores DST also ignores circuit breakers. It assumes market tradability because it was never told otherwise.
flowchart TD
A["UTC 20:30 — 4:30 PM EDT\nNYSE closed 30 min ago"]
A --> B["Vulnerable Bot\n(UTC-5 hardcoded)"]
A --> C["Safe Bot\n(oracle-based)"]
B --> D["Calculates 15:30 EST\n'Market is OPEN'"]
D --> E["Health factor < 1.0\nFire liquidation"]
E --> F["NYSE: settlement FAILS\nunderlying cannot be sold"]
F --> G["Protocol absorbs\n~$13M–$19.5M bad debt"]
C --> H["Query oracle\nGET /v5/status?mic=XNYS"]
H --> I["Receipt: status=CLOSED\nEd25519 signature verified"]
I --> J["HALT — market not\nconfirmed OPEN"]
J --> K["No execution\nNo bad debt"]
style G fill:#c0392b,color:#fff
style K fill:#27ae60,color:#fff
style F fill:#e74c3c,color:#fff
style J fill:#2ecc71,color:#fff
NYSE operates three levels of market-wide circuit breakers:
| Level | Trigger | Action |
|---|---|---|
| 1 | S&P 500 down 7% | 15-minute halt |
| 2 | S&P 500 down 13% | 15-minute halt |
| 3 | S&P 500 down 20% | Market closes for the day |
In March 2020, Level 1 triggered four times in ten days. A liquidation bot that assumes continuous market availability kept firing during each halt. The settlement failed or executed against insufficient liquidity.
Individual securities have their own halts (LULD, regulatory, news-pending). These can stop a single stock while the broader market continues trading — but a bot holding that stock as collateral faces the same settlement failure.
DST is the predictable version. Circuit breakers are the unpredictable version. Both expose the same structural weakness in your session logic.
vulnerable_bot.py — an RWA liquidation bot with hardcoded UTC-5 offset.
On Monday March 9, 2026 (first trading day after DST):
- At 4:30 PM EDT (market closed 30 min ago), the bot calculates 3:30 PM EST and believes the market is open
- OUSG drops 15% in a post-close move. The bot fires a liquidation against a closed NYSE.
- Settlement fails because the underlying cannot be sold.
- The protocol absorbs bad debt.
python vulnerable_bot.py
safe_bot.py — same liquidation logic, but with a 3-line integration with
Headless Oracle.
# Line 1: Query the oracle
receipt = check_oracle("XNYS")
# Line 2: If oracle failed or returned non-OPEN, HALT
if not receipt or receipt.get("status") != "OPEN":
halt()
# Line 3: Only execute if oracle says OPEN and signature is valid
execute_liquidation()The safe bot never computes timezones. It asks a signed oracle whether NYSE is open. If the
answer isn't OPEN with a valid Ed25519 signature, it halts. This handles DST, circuit
breakers, exchange halts, and unexpected closures — all with the same three lines.
pip install requests pynacl
python safe_bot.py
Bad debt exposure modeled across RWA lending scenarios:
| Scenario | Collateral TVL | CR | NAV Drop | Estimated Bad Debt |
|---|---|---|---|---|
| Conservative | $963M | 130% | 10% | $7.4M – $9.6M |
| Base | $1.3B | 150% | 15% | $13M – $19.5M |
| Severe | $2B+ | 150% | 20% | $26M – $40M |
Based on Ondo OUSG TVL ($1.3B, January 2026) and Kamino Finance ($2.6B TVL with tokenized equity collateral via Backed Finance xStocks).
This is not theoretical. The Mango Markets exploit (2022) demonstrated how stale-price liquidation logic can be exploited for $117M in losses. The DST failure is harder to detect — there's no attacker, just a bot that computes the wrong time and fires into a closed market.
Headless Oracle returns a cryptographically signed market status receipt on every call:
- Query:
GET /v5/status?mic=XNYSwith your API key - Response: JSON receipt with
statusfield (OPEN,CLOSED,HALTED, orUNKNOWN) - Verification: Every response is Ed25519 signed. Verify locally before trusting.
- Fail-closed: Timeout, bad signature, or
UNKNOWN→ halt execution.
{
"receipt_id": "550e8400-e29b-41d4-a716-446655440000",
"issued_at": "2026-03-09T20:30:00.000Z",
"mic": "XNYS",
"status": "CLOSED",
"source": "SCHEDULE",
"terms_hash": "v5.0-beta",
"public_key_id": "key_2026_v1",
"signature": "a1b2c3..."
}Paste any receipt into the browser-based verifier (zero server calls, purely client-side): headlessoracle.com/verify
Key ID: key_2026_v1
Algorithm: Ed25519
Public: 03dc27993a2c90856cdeb45e228ac065f18f69f0933c917b2336c1e75712f178
Python:
import requests, json
from nacl.signing import VerifyKey
PUBLIC_KEY = "03dc27993a2c90856cdeb45e228ac065f18f69f0933c917b2336c1e75712f178"
def is_market_open(mic="XNYS"):
"""Returns True only if oracle confirms market is OPEN with valid signature."""
try:
resp = requests.get(
f"https://headlessoracle.com/v5/status?mic={mic}",
headers={"X-Oracle-Key": "YOUR_API_KEY"},
timeout=4,
)
receipt = resp.json()
# Verify Ed25519 signature
payload = {k: receipt[k] for k in [
"receipt_id", "issued_at", "mic", "status",
"source", "terms_hash", "public_key_id"
]}
canonical = json.dumps(payload, separators=(",", ":"))
vk = VerifyKey(bytes.fromhex(PUBLIC_KEY))
vk.verify(canonical.encode(), bytes.fromhex(receipt["signature"]))
return receipt["status"] == "OPEN"
except Exception:
return False # Fail-closed: any error = market not confirmed open
# In your liquidation bot:
if is_market_open() and health_factor < 1.0:
execute_liquidation()
else:
defer_liquidation()JavaScript / Node.js:
const PUBLIC_KEY = "03dc27993a2c90856cdeb45e228ac065f18f69f0933c917b2336c1e75712f178";
async function isMarketOpen(mic = "XNYS") {
try {
const resp = await fetch(
`https://headlessoracle.com/v5/status?mic=${mic}`,
{ headers: { "X-Oracle-Key": "YOUR_API_KEY" }, signal: AbortSignal.timeout(4000) }
);
const receipt = await resp.json();
const { signature, ...payload } = receipt;
const ordered = {
receipt_id: payload.receipt_id, issued_at: payload.issued_at,
mic: payload.mic, status: payload.status, source: payload.source,
terms_hash: payload.terms_hash, public_key_id: payload.public_key_id
};
const msg = new TextEncoder().encode(JSON.stringify(ordered));
const sig = Uint8Array.from(signature.match(/.{2}/g).map(h => parseInt(h, 16)));
const keyBytes = Uint8Array.from(PUBLIC_KEY.match(/.{2}/g).map(h => parseInt(h, 16)));
const key = await crypto.subtle.importKey("raw", keyBytes, { name: "Ed25519" }, false, ["verify"]);
const valid = await crypto.subtle.verify("Ed25519", key, sig, msg);
return valid && receipt.status === "OPEN";
} catch {
return false; // Fail-closed
}
}The same failure affects UK and European RWA protocols. On March 29, 2026, clocks spring forward (GMT→BST, CET→CEST). Any bot using hardcoded UTC+0 or UTC+1 offsets for LSE (XLON) or Euronext Paris (XPAR) will miscalculate session hours from March 30 onwards.
Headless Oracle covers XLON, XPAR, XJPX, XHKG, and XSES — all DST transitions handled server-side via IANA timezone names. No hardcoded offsets anywhere.
Clone and run the exploit scenario end-to-end in under 60 seconds:
npm install
npm run simulate:exploitSimulates the vulnerable bot misfiring, the safe bot halting, and three
oracle failure modes (unreachable, UNKNOWN, circuit breaker HALTED) — no network calls required.
src/simulate.ts — full exploit simulation with console output.
src/verifySignature.ts — isolated Ed25519 verifier (Node 18+, no external deps).
Relying on a signed oracle shifts the attack surface from time logic to key trust. These are the residual risks and how the protocol addresses them.
1. Oracle key compromise
If the oracle's Ed25519 private key is stolen, an attacker could issue
forged OPEN receipts during a halt. Mitigation: the oracle publishes a
versioned public key (public_key_id field); protocols should pin the
expected key ID and treat unexpected key IDs as invalid. Rotate and republish
if compromised.
2. Replay attacks (stale receipts)
A valid OPEN receipt from 09:30 AM could be replayed at 4:30 PM. Mitigation:
check issued_at before trusting a receipt — reject any receipt older than
your acceptable staleness window (e.g., 60 seconds for on-chain keepers).
3. Oracle unavailability If the oracle endpoint is unreachable (DDoS, outage), a fail-closed bot cannot execute any liquidations during that window, even legitimate ones. This is the intended trade-off: undercollateralized positions temporarily accumulate risk rather than settle against a closed market. Consider a secondary oracle or a circuit-breaker fallback.
4. Receipt freshness (clock skew)
On-chain contracts cannot query external APIs directly. Off-chain keepers
must post the receipt on-chain. Ensure the issued_at timestamp is
validated relative to block.timestamp with an appropriate staleness bound
in the contract, not just off-chain.
5. Single-oracle SPOF A single oracle is a centralization risk. For production protocols, consider a multi-oracle quorum (e.g., 2-of-3 signed receipts from independent sources) before executing. The Ed25519 verification module is designed to be composable for this pattern.
- Website: headlessoracle.com
- Receipt Verifier: headlessoracle.com/verify
- API Docs: headlessoracle.com/docs
- Live Demo (no auth):
curl https://headlessoracle.com/v5/demo
MIT
Built with Claude Code by Anthropic.