Skip to content

LembaGang/dst-exploit-demo

Repository files navigation

RWA Liquidation Bot Exploit Demo — DST Phantom Hour & Circuit Breaker Risk

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.


The Problem

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_close

This 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 &lt; 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
Loading

Circuit Breakers: The Unpredictable Version of the Same Bug

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.


The Vulnerable Bot

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

The Safe Bot

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

The Numbers

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.


How the Oracle Works

Headless Oracle returns a cryptographically signed market status receipt on every call:

  1. Query: GET /v5/status?mic=XNYS with your API key
  2. Response: JSON receipt with status field (OPEN, CLOSED, HALTED, or UNKNOWN)
  3. Verification: Every response is Ed25519 signed. Verify locally before trusting.
  4. 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..."
}

Verify a receipt yourself

Paste any receipt into the browser-based verifier (zero server calls, purely client-side): headlessoracle.com/verify

Public Key

Key ID:    key_2026_v1
Algorithm: Ed25519
Public:    03dc27993a2c90856cdeb45e228ac065f18f69f0933c917b2336c1e75712f178

Integration

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
  }
}

March 29, 2026 — UK/EU DST

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.


TypeScript Simulation

Clone and run the exploit scenario end-to-end in under 60 seconds:

npm install
npm run simulate:exploit

Simulates 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).


Security Considerations

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.


Links

License

MIT


Built with Claude Code by Anthropic.

About

The 2026 Phantom Hour: Why RWA Liquidation Bots Break on March 8 — DST exploit demo with vulnerable vs safe bot

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors