# egg — writing - Full Public Archive

> Writing on crypto, cryptography, distributed systems, and whatever else is on my mind.

Generated from public posts only. Private and draft posts are omitted.

- Site: https://blog.eggtech.io
- Author: Kai Aldag
- Generated at build/request time from 5 public posts.
## Introducing fast-fill: instant cross-chain transfers on the bridge you already trust

> A thin optimistic-fill layer on Circle CCTP v2 and LayerZero OFT: funds in seconds when a relayer fronts them, and never worse than the bridge if no one does. No escrow, no liquidity pool, no new trust assumption. Proven on mainnet.

- Canonical URL: https://blog.eggtech.io/posts/announcing-fast-fill
- Markdown URL: https://blog.eggtech.io/posts/announcing-fast-fill/index.md
- Published: 2026-07-01
- Updated: 2026-07-01
- Author: Kai Aldag
- Reading time: 25 min read
- Tags: crypto, cross-chain, systems

### Referenced Links

- [Live demo](https://fast-fill.vercel.app)
- [GitHub](https://github.com/KaiCode2/fast-fill)
- [Architecture](https://github.com/KaiCode2/fast-fill/blob/main/docs/ARCHITECTURE.md)

---

<LogoBanner src="/images/announcing-fast-fill/fastfill-logo.png" alt="Fast Fill" />

## TL;DR

Canonical, message-based bridges — Circle's **CCTP** and LayerZero's **OFT** — are the safest way to move assets across chains, because the destination only ever mints against a cryptographically verified message. The catch is **latency**: you wait for source-chain finality and off-chain attestation before the funds appear. That's seconds in the best case and many minutes in the worst.

The usual fix — a "fast bridge" with its own liquidity pools, escrow contracts, and solver inventory — buys speed by adding a whole new system to trust, and can leave you *worse off* than the bridge if it fails.

**fast-fill takes a different route.** It rides the bridge's own authenticated channel and lets an external relayer *pre-pay* your transfer on the destination chain **before** the bridge message is verified, for a small, user-priced premium. When the bridge finally settles, the in-flight bridged funds reimburse the relayer. The relayer never holds your money in escrow; the transfer either arrives early (a relayer filled it) or on time (the bridge delivers it) — never late, never for more than you agreed.

- ⚡ **Best case:** funds in ~1.5 seconds.
- 🛟 **Worst case:** identical to using the bridge directly — same arrival time, zero premium.
- 🔒 **No escrow, no pool:** the bridged funds *are* the relayer's reimbursement.
- 🧾 **Trustless & permissionless filling:** a relayer that fills a fake order can only lose its *own* money.
- ✅ **Live on mainnet:** real USDC and real Circle attestations — the headline optimistic fill proven on Base → Arbitrum, with the executor-routed path live across Base, Optimism, and Arbitrum.

This post goes deep: the problem, the mechanism, the economics (with numbers), the security model, and the mainnet transaction record.

---

## The problem: you shouldn't have to choose between *fast* and *trustless*

Moving a dollar of USDC from Base to Arbitrum sounds simple. Under the hood, a canonical bridge does something careful:

1. **Burn** (or lock) the token on the source chain.
2. Wait for the source chain to reach a **finality threshold** so the burn can't be reorged away.
3. An off-chain **attestation service** (Circle's Iris for CCTP, the DVN set for LayerZero) signs off on the message.
4. Someone submits the verified message on the destination, which **mints** (or unlocks) the token.

Every step of that is what makes it *safe* — the destination mints only against a message it can cryptographically verify. But steps 2 and 3 are also why it's *slow*. Depending on the route and the finality mode you pick, "slow" ranges from ~15 seconds to well over ten minutes.

The industry's answer has been the **fast bridge / intent-solver** model: liquidity providers pre-position inventory on every chain, an escrow contract holds your input, and a solver network competes to front you the output. It's fast — but look at what you took on:

- A **new liquidity pool** to trust (and to be drained if there's a bug).
- An **escrow contract** holding your funds mid-flight.
- A **solver's promise** — and a failure mode where a solver front-runs, grief-fails, or vanishes, potentially leaving you worse off than if you'd just used the bridge.

The insight behind fast-fill is that **you don't need any of that** to go fast. The canonical bridge is *already* delivering your funds to the destination — reliably, on its own authenticated rails. All that's missing is someone willing to advance you the money for the few seconds or minutes until it lands. If you can pay that person a small premium **and** make it impossible for them to steal or strand your funds, you get bridge-grade safety at solver-grade speed.

---

## The idea: an optimistic fill on the bridge's own rails

Here is the entire mechanism in one sequence. The user initiates a normal bridge transfer, but the destination recipient is set to the fast-fill **adapter**, and the transfer's `hookData` (CCTP) or `composeMsg` (OFT) carries an encoded **Order**. A relayer watching for `OrderCreated` events can then fill the order on the destination *before* the bridge verifies anything.

```mermaid
sequenceDiagram
    actor U as User
    participant S as SourceAdapter
    participant B as Bridge
    actor R as Relayer
    participant D as DestAdapter
    actor C as Recipient

    U->>S: initiate — pull funds, encode Order in hook
    S->>B: burn / send, recipient = DestAdapter
    S-->>R: emit OrderCreated
    Note over R,C: BEFORE the bridge verifies
    R->>D: fill(order)
    D->>C: pay outputAmount minus fee  [instant]
    Note over D: record filler = Relayer
    B->>D: message verified, funds delivered
    D->>R: reimburse outputAmount  (else recipient, if unfilled)
    D->>C: pay surplus
```

The two outcomes are worth stating precisely, because the *worst* case is the whole point:

- **Best case (a relayer fills):** the recipient is paid `outputAmount − fee` in seconds. When the bridge message later settles, the arrived funds reimburse the relayer exactly `outputAmount`, and any surplus goes to the recipient.
- **Worst case (no one fills):** the bridge settles as it always would, and the recipient receives the full arrived amount — at the same time, and for the same cost, as if fast-fill weren't in the path at all.

There is no third outcome where the user is late *and* out-of-pocket. fast-fill can only make a transfer faster or leave it unchanged.

---

## The load-bearing invariant: why filling is trustless

Everything rests on one line:

```
orderId = keccak256(abi.encode(order))
```

The **same** `orderId` is computed in three places: when the source encodes the order into the bridge payload, when a relayer fills, and when the destination settles the authenticated message.

```mermaid
flowchart LR
    A["source: encode Order<br/>into hookData / composeMsg"] --> B["relayer: fill(order)<br/>recompute orderId"]
    A --> C["destination: settle decodes the<br/>authenticated Order, recompute orderId"]
    B -. "must match" .-> C
```

Because the order data settles through the **bridge's authenticated channel** — a Circle-attested message or a LayerZero-verified compose — a relayer that fills against a *fabricated* order computes an `orderId` that no settling message will ever reproduce. That relayer is simply **never reimbursed**.

This flips the usual trust model on its head. **A careless or malicious filler can only ever lose its own funds** — never the recipient's, never the protocol's, never another filler's. There is nothing to steal by filling a bogus order; you'd just be gifting money to a stranger. That's exactly why fast-fill makes filling **permissionless by default**: there's no allowlist, no staking, no reputation system, because the economics are self-punishing.

---

## How it works, mechanically

### The Order

An order is a plain struct, encoded verbatim into the bridge's hook payload. Transport identifiers (CCTP domains, LayerZero endpoint ids) are deliberately *not* in it — they're resolved at the contract edges — so the `orderId` stays stable and a relayer only needs the emitted order to reconstruct the hash.

```solidity
struct Order {
    uint8   bridgeType;           // 0 = CCTP, 1 = OFT
    uint32  srcChainId;
    uint32  dstChainId;
    bytes32 sender;               // user on the source chain
    bytes32 recipient;            // final recipient (canonical address.toBytes32())
    bytes32 inputToken;           // pulled from the user on the source
    bytes32 outputToken;          // delivered on the destination
    uint256 inputAmount;          // pulled from the user (pre bridge fee)
    uint256 outputAmount;         // deterministic worst-case arriving amount the filler is owed
    uint64  nonce;                // per-source-adapter monotonic counter
    uint64  startTime;            // absolute source timestamp — pricing baseline
    uint64  expectedDeliveryTime; // premium decays to 0 at/after this point
    uint256 discountRate;         // WAD/second — user-chosen time-premium accrual
    uint256 baseFee;              // flat fee (output-token units) owed on any fill
    uint64  callbackGasLimit;     // gas forwarded to the recipient's onFastFill hook
    bytes   hookData;             // optional destination-execution payload
}
```

### The order lifecycle

Every order moves through a single-slot status machine. The record packs into one storage slot (`address filler; FillStatus status; uint40 fillTime`), so the hot path is cheap.

```mermaid
stateDiagram-v2
    [*] --> None
    None --> Filled: fill() — relayer fronts payout to recipient
    None --> Settled: settle() with no fill — recipient gets everything
    Filled --> Settled: settle() — filler reimbursed, surplus to recipient
    Settled --> [*]
```

- `fill` requires `status == None` (rejects double-fills and fill-after-settle).
- `settle` requires `status != Settled` — the bridge's own nonce is the first, independent replay guard; this is defense-in-depth.
- `Settled` is terminal. The destination contract's balance is the reimbursement pool, and every order settles exactly once.

### Settlement authentication

The subtle part is making sure that *only* a genuine, bridge-verified message can settle an order — and that nobody can forge a burn to our adapter to poison a real order's id. fast-fill solves this with **CREATE2-deterministic addresses**: each adapter's counterpart on every chain is literally *the same address*, `address(this)`.

For CCTP's direct path (`mintFee == 0`), the source sets `mintRecipient = destinationCaller = address(this)`, so only this adapter can call `receiveMessage`. After it mints and consumes the CCTP nonce, it additionally requires that the burn's `messageSender == address(this)`:

```mermaid
flowchart TB
    M["relayer calls settle(message, attestation)"] --> RM["MessageTransmitterV2.receiveMessage"]
    RM -->|"bad attestation / used nonce / caller != destinationCaller"| REV1["revert (rolls back)"]
    RM -->|"valid"| MINT["mint (amount - feeExecuted) USDC to this adapter,<br/>consume nonce"]
    MINT --> P["parse message via BurnMessageV2Lib"]
    P --> C1{"mintRecipient == this?"}
    C1 -->|no| R2["revert MintRecipientMismatch"]
    C1 -->|yes| C2{"config domain == srcDomain?"}
    C2 -->|no| R3["revert UntrustedSourceDomain"]
    C2 -->|yes| C3{"messageSender == address(this)?"}
    C3 -->|no| R4["revert UntrustedSender (anti-forgery)"]
    C3 -->|yes| OK["_settle: reimburse filler or pay recipient"]
```

Anyone can craft their own CCTP burn to our adapter with a fabricated order in `hookData` — but they can't make the burn's `messageSender` *be* our adapter address, so such a burn can never settle here. That's the check that stops an attacker from pre-settling a real order's id and stranding the genuine transfer.

The **OFT** path enforces the analogous property with three gates on `lzCompose`: the caller must be the LayerZero endpoint, the local `from` OFT must be the one in the registry, and the embedded `composeFrom` must equal `address(this)`.

Settlement itself is deliberately boring and safe:

```solidity
owed    = min(arrived, order.outputAmount);
surplus = arrived - owed;
if (filled) { payout(filler, owed); payout(recipient, surplus); }
else        { payout(recipient, arrived); }
status = Settled;
```

`_payout` is a return-value-checked transfer. If a push fails — say the recipient is USDC-blacklisted or reverts — it credits a `claimable[account][token]` ledger instead of reverting, so a hostile recipient can never brick settlement. Effects (status) are written before any external transfer, and every state-changing entrypoint is `nonReentrant`.

---

## The economics

fast-fill's pricing is intentionally simple on-chain and factual off-chain. The **contract** enforces a small signed fee curve; the **demo and relayers** choose sane default values from live market data (gas benchmarks, destination gas price, ETH/USD, Circle's own fee API).

### The premium curve

The fee owed to a filler has two additive parts: an optional flat `baseFee` (a fixed price for the service, owed on any fill) and a **time premium** that is largest right after the order's `startTime` — when the relayer must front capital the longest — and decays linearly to zero at `expectedDeliveryTime`. A late or never-filled order costs the user nothing beyond the bridge.

```
timeSaved = max(0, expectedDeliveryTime − max(fillTime, startTime))
rate      = min(discountRate · timeSaved, maxFeeRate)          [WAD]
timeFee   = outputAmount · rate / 1e18
fee       = min(baseFee + timeFee, outputAmount)
payout    = outputAmount − fee        (paid to the recipient at fill)
```

![The optimistic-fill premium decays to zero at the bridge's delivery time](/images/announcing-fast-fill/pricing-curve.png)

The chart shows all three regimes on an illustrative $1,000 transfer with a 60-second delivery window:

- **The cap plateau (left).** `maxFeeRate` is a per-adapter governance ceiling on the *rate* (deployed at **0.5%**). A user can request an aggressive premium, but the adapter clamps it. In the live Base→Arbitrum run below, the user's premium saturated exactly this 0.5% cap.
- **The linear decay (middle).** As the bridge's expected delivery time approaches, the relayer would be fronting capital for less and less time — so the premium it can earn shrinks proportionally.
- **The flat floor (right).** After the delivery window, only the flat `baseFee` remains (here $0.05, a stand-in for the fill's gas). Beyond that, the premium is zero — filling late earns nothing but the base fee.

Both `baseFee` and `discountRate` are **per-order, user-chosen**. Set `baseFee = 0` for a pure time curve, or `discountRate = 0` for a flat fee. Timing is derived **on-chain**: `startTime = block.timestamp`, and the user signs a *relative* `deliveryWindow`, so `expectedDeliveryTime = block.timestamp + deliveryWindow`. Signing a relative window (rather than an absolute timestamp) means the window a user agreed to holds no matter when a sponsoring relayer actually submits.

### Where a fast-fill fee actually comes from

It's fair to ask: *what does going fast actually cost me?* The demo derives every signed value from factual inputs, so we can decompose a real quote. For a $1,000 fast CCTP transfer to Arbitrum with Circle's Relay Mint enabled:

![Anatomy of a fast-fill fee — the relayer's own cut is sub-cent](/images/announcing-fast-fill/fee-composition.png)

The headline: **everything fast-fill itself adds is a few cents.** The largest line item, the Circle protocol fee, is the *bridge's* — you'd pay it going direct too. The mint-relay fee (which pays whoever submits the destination mint), the fill's gas floor, and the actual speed premium together come to about three cents on $1,000. The time premium in particular is tiny by design: it prices the relayer's capital at a **10% APR opportunity cost** over the seconds it fronts the money, and 10% APR for a few seconds rounds to almost nothing. Users see all of these signed values before they submit, and relayers independently recompute and reject stale or underpriced quotes.

### The overhead of a thin wrapper

Because fast-fill rides the bridge's own message rather than adding a second one, its gas overhead is modest. Measured against a **byte-identical in-EVM mock bridge** (so the bridge's own cost cancels in the subtraction), here is what fast-fill itself adds per operation:

![What the thin wrapper costs: fast-fill's gas overhead per operation](/images/announcing-fast-fill/gas-overhead.png)

For context, a *real* CCTP burn through the adapter costs **≈167,638 gas end-to-end** on an Ethereum-mainnet fork, of which the bridge is the large majority. fast-fill never adds an escrow write or a second cross-chain message — it threads its order through the payload the bridge was already carrying. (On the L2s where it's deployed, the dollar cost of all of this is a fraction of a cent.)

### Speed: who actually waits

This is the part that matters to a user. When a relayer fills, **the user has usable funds in about 1.5 seconds** — regardless of how slow the underlying bridge is. The relayer, not the user, absorbs the bridge's settlement latency, and is paid the premium precisely for taking on that wait and that capital risk.

![When a relayer fills, the user waits ~1.5s — not for the bridge](/images/announcing-fast-fill/latency.png)

*(Bridge settlement times are approximate and route/finality-dependent; the point is the order-of-magnitude gap.)*

### The relayer's business

A fast-fill relayer earns from **two independent roles**, and neither requires a liquidity pool:

1. **Optimistic filling.** When it holds destination inventory and a fill clears its gas-backed profitability floor, it calls `fill(order)`, pays the recipient instantly, and is reimbursed `outputAmount` at settlement — pocketing the premium. Its capital is tied up only for the bridge-latency window (seconds to minutes), which is exactly what the time premium prices.
2. **CCTP mint relaying.** For orders that opt into Circle's Relay Mint (`mintFee > 0`), the relayer polls Circle's attestation and calls `CctpExecutor.execute(...)`, which mints the USDC, pays it the `mintFee`, and forwards the rest to the adapter. This needs only gas — no inventory — and can be done even for orders it didn't fill.

Crucially, there's **no relayer liquidity pool and no protocol escrow**. The in-flight bridged funds are the reimbursement. That makes the capital model dramatically simpler than a solver network: a relayer needs enough working inventory to cover the fills in flight during a bridge-latency window, and nothing more.

---

## `CctpExecutor`: a standalone public good

CCTP has a rough edge: after a burn, *someone* has to submit the destination mint, and Circle's own forwarding service is a paid, centralized convenience. fast-fill ships a generic, permissionless replacement — `CctpExecutor` — that knows about CCTP messages and USDC but nothing about fast-fill orders. Any CCTP integrator can use it.

A source burn sets `mintRecipient = destinationCaller = CctpExecutor` and puts an envelope in `hookData`:

```solidity
struct ExecHook {
    uint256 mintFee;  // USDC paid to whoever calls execute()
    bytes32 target;   // forward recipient OR hook receiver contract
    uint64  gasLimit; // 0 => forward-only, >0 => call target.onCctpExecute(...)
    bytes32 refundTo; // claimant if hook execution fails
    bytes   payload;  // integrator-defined data
}
```

On the destination, **anyone** calls `execute(message, attestation)`: the executor mints the USDC to itself, pays `mintFee` to the caller, and either forwards the remainder to `target` (forward-only mode) or calls `target.onCctpExecute(...)` in the same atomic frame (hook mode). For a fast-fill order, the hook is what settles it.

The accounting for a routed fast-fill order is unchanged — `mintFee` cancels out because it's reserved on the source side:

```
forward − outputAmount
  = (inputAmount − feeExecuted − mintFee) − (inputAmount − maxFee − mintFee)
  = maxFee − feeExecuted ≥ 0
```

so the filler is still reimbursed exactly `outputAmount`, and the recipient still gets the `maxFee − feeExecuted` surplus.

**Batching and directed fees.** CCTP v2 has no on-chain batch mint, so a relayer amortizes the per-transaction base cost by batching the *calls*: `executeBatch(messages[], attestations[], feeRecipient)` relays many messages in one transaction with **partial success** — each item runs in its own `try`/`catch` under a single reentrancy guard, so an item that reverts (already relayed, stale attestation) is skipped and its CCTP nonce stays redeemable, rather than aborting the batch. `FastFillBase` has the symmetric `fillBatch`. Both also support directed payout (`executeTo` / `fillTo`) so a hot wallet can relay while a treasury collects — but these only ever move the relayer's *own* fee/reimbursement; the user-signed recipient and the source-attested delivery target stay authoritative.

---

## Destination executions: act on funds the instant they arrive

An order can carry `hookData` and a user-signed `callbackGasLimit` (capped at **5,000,000 gas**). When the funds are delivered — whether by an optimistic fill or by the bridge settling — a recipient *contract* receives an `onFastFill(orderId, token, amount, hookData)` callback in the **same atomic frame** as the transfer. This is how you bridge-and-do-something in one shot: deposit into Aave, swap on Uniswap, fund a smart account.

The failure policy is governed by the receiver's own revert data, and it's designed so funds are **never stranded**:

```
onFastFill succeeds            → funds delivered, execution ran
reverts RedirectFunds(dest)    → funds delivered to dest instead
reverts anything else / OOG    → funds credited to claimable[recipient] (recover via claim())
```

This mirrors CCTP v2's atomic-hook semantics but is **strictly safer**: because the transfer and callback share one revertable frame, a deterministically-failing hook can never strand the bridged funds. The demo ships two validated hooks — a **Uniswap V3 swap** and an **Aave V3 deposit** — both deployed on Base, Optimism, and Arbitrum, each leaning on the revert-to-redirect rule so a failed action degrades to a plain transfer of the original token to the user.

The hardening here is meticulous. The callback is gas-capped and **return-bomb-safe**. The forwarded gas budget is *guaranteed* by an exact in-frame check that accounts for both nested EIP-150 63/64 deductions — so a relayer that under-funds the transaction reverts the whole fill (forcing a retry) rather than quietly starving the callback. And it all runs behind the existing `nonReentrant` guard with effects written first, so a hostile receiver can't re-enter or claw back funds it wasn't owed.

---

## Gasless and sponsored transfers

Neither users nor relayers should have to hold gas on every chain just to sign. Both adapters support signature-based funding:

- **EIP-2612 single-tx** — batch a `selfPermit` before the action via `multicall([selfPermit(token, …), initiateCCTP(…)])`, so approval and bridge land in one transaction. Works for USDC and USD₮0.
- **Permit2 sponsored intent** (`initiate*For` / `fillFor`) — a user *signs* an off-chain order intent; a relayer submits it and pays the gas, while funds are pulled from the **signer** via Permit2. The signature commits to a **witness** binding the recipient, amounts, timing, pricing, the destination execution, **and** the bridge mode (CCTP fast-vs-finalized + executor routing, or OFT executor options). So a submitting relayer can't re-price, re-time, re-route, or change the destination action of a signed intent. This is proven against the *real* Permit2 in a fork test, including rejected attempts to tamper with the recipient and to flip the transfer speed.

---

## Configuration: one immutable registry, deterministic addresses everywhere

All chain-specific data — CCTP/LayerZero addresses, domains, endpoint ids, per-chain USDC, and each OFT's per-chain `(oft, token)` pair — lives in a single **immutable `FastFillConfig`** contract, deployed once via **CREATE2** so it lands at the *same address on every chain*.

```mermaid
flowchart TB
    subgraph BASE["FastFillBase (abstract)"]
        OB["order book + status machine"]
        FILL["fill() · fillFor()"]
        SET["_settle()"]
    end
    subgraph CB["CallbackExecutor (abstract)"]
        PAY["_payout + claim ledger"]
        HOOK["atomic transfer + callback"]
    end
    CA["CctpAdapter<br/>initiateCCTP() · settle() · onCctpExecute()"]
    CE["CctpExecutor<br/>execute() · forward / hook mode"]
    OA["OftAdapter<br/>initiateOFT() · lzCompose()"]
    CA -- inherits --> BASE
    OA -- inherits --> BASE
    BASE -- inherits --> CB
    CE -- inherits --> CB
    CFG["FastFillConfig<br/>immutable CREATE2 registry"]
    CA --> CFG
    CE --> CFG
    OA --> CFG
```

The payoff is that there is **no per-counterpart wiring to get wrong**. Because the registry, the executor address, the owner, and the fee cap are identical across chains, every adapter is itself CREATE2-deterministic — so the counterpart adapter on the far chain is simply `address(this)`, which is exactly the property the settlement-authentication checks rely on. There are **no owner setters** for addresses, domains, or counterparts; those are read from the registry at call time. And on every use, the adapter **cross-checks its local domain / endpoint id / token against the live bridge contracts** and reverts on any mismatch — a wrong constant can't silently ship. The owner-gated surface is tiny: `setMaxFeeRate` and `setPaused`.

### Many tokens, one code path

`OftAdapter` is generic — parameterized by an `oftId` — and `OftAdapterFactory` stamps out one instance per token at a deterministic, cross-chain-stable address. Onboarding a new OFT (USDe, sUSDe, ENA, USDtb, …) is **purely additive**: add the per-chain rows to the registry, assign an id, and call `deploy(oftId)`. No new adapter code. Because each token gets its own deployment, **every token's reimbursement pool is physically isolated** — a decode or auth bug in one adapter can never reach another's funds.

One honest caveat: a *deployed* adapter is just an address on our side — it doesn't guarantee the token issuer's live OFT has enabled peers for a given route. The USD₮0, USDe, sUSDe, and ENA adapters are deployed on all three chains; USDtb is defined in the registry but not yet stamped out by the factory; and some Ethena routes (e.g. USDe Arbitrum ⇄ Base) currently revert `NoPeer` because the issuer hasn't wired those peers yet. The adapter framework is ready; peer availability is the token issuer's to enable.

---

## Proven on mainnet

fast-fill isn't a testnet demo. The CCTP path has been run **end-to-end on Ethereum-mainnet L2s with real USDC** and Circle's real attestation service.

### The headline run: $1, Base → Arbitrum, filled before settlement

A $1 transfer to a fresh recipient, with a user-chosen premium that capped at the adapter's 0.5%. The relayer fronted the funds on Arbitrum **before** the bridge settled, then was reimbursed when it did.

![Live on mainnet: a $1 Base→Arbitrum transfer, filled before the bridge settled](/images/announcing-fast-fill/waterfall.png)

Reading the waterfall (all figures are the real on-chain record, in USDC):

| Party | Result |
|---|---|
| **Recipient** | **$0.994871 total** — of which **$0.994801 arrived at fill time**, in ≈1.5 seconds, *before* CCTP settled |
| **Relayer** | net **+$0.004999** — the 0.5% premium earned for fronting capital across the bridge-latency window |
| **Bridge** | $0.000200 reserved as `maxFee`; the unspent surplus ($0.000070) flowed to the recipient at settlement |

And the fallback was demonstrated too: two baseline runs with **no relayer** simply delivered the funds when CCTP settled — the recipient received `999,870` units on a $1 transfer (the 130-unit haircut is Circle's fast-transfer fee), at no premium. That's the worst case, and it's exactly the bridge.

### The executor path, live

The newer `CctpExecutor`-routed path has since been deployed on Base, Optimism, and Arbitrum and smoke-tested with live USDC for both **unfilled** and **optimistically filled** orders. One routed Optimism→Arbitrum fill, for example, paid the recipient at fill time, then at settlement had the executor pay the mint relayer its `mintFee`, reimburse the filler, and route the surplus to the recipient — all in the accounting described above, on-chain, with real money.

### The OFT path, de-risked on forks

The LayerZero path targets USD₮0 and the Ethena OFT suite (USDe, sUSDe, ENA). It's proven against the **real USD₮0 OFT and LayerZero endpoint on an Optimism fork** — driving the real mint and the real compose to settle an order — plus live fact-checks of every OFT's on-chain configuration across four chains. A live USD₮0 *source* transfer has been broadcast Optimism→Arbitrum (pending the pathway's source confirmations at the time of writing, not yet confirmed delivered); a full public OFT demo is the next milestone.

### Deployed addresses

All contracts are CREATE2-deterministic — the **same address on Base, Optimism, and Arbitrum** — from a single deployer, with `maxFeeRate = 0.5%`:

| Contract | Address |
|---|---|
| `FastFillConfig` | `0xaec766479DB174110958Bc45D141A2C5eF693DF5` |
| `CctpExecutor` | `0xAFc7bBc0B5fD7A4d9b936349cfE991e5bC6E2a80` |
| `CctpAdapter` (executor-enabled) | `0x9FA37faBfA1Fd31Afe5A5F93e1c4Cd986b27bA75` |
| `OftAdapterFactory` | `0x84Bb5d3142024da8d61CBEE0A4c722a1650fbFcb` |
| `OftAdapter` (USD₮0 / USDe / sUSDe / ENA) | one deterministic address per token |

The demo hooks (`UniswapSwapHook`, `AaveDepositHook`) are deployed and source-verified on all three chains as well.

---

## The security model, at a glance

fast-fill's threat model is small because its trust surface is small. A condensed version of the full table:

| Vector | Defense |
|---|---|
| Double-fill / fill-after-settle | `fill` requires `status == None`. |
| Replay of a bridge message | The bridge consumes its own nonce; plus a `status != Settled` app guard. |
| Fake-order fill | Non-matching `orderId` ⇒ filler is never reimbursed (self-punishing). |
| Forged CCTP burn to our adapter | `messageSender == address(this)` rejects burns not initiated by our same-address adapter. |
| Routed CCTP nonce griefing | `destinationCaller = CctpExecutor`; only the executor can consume the message. |
| Caller redirecting bridged funds | `executeTo`/`fillTo` redirect only the relayer's *own* fee; recipient & delivery target stay authoritative. |
| Forged OFT compose | Three gates: endpoint caller, local OFT, `composeFrom == address(this)`. |
| Misconfigured registry | Local domain/eid/token cross-checked live against the bridge contracts at construction; a mismatch reverts the deploy. |
| Sponsor altering a signed intent | Permit2 witness binds the intent *and* the bridge mode; tampering recovers a different signer and reverts. |
| Hostile destination receiver | `onFastFill` is gas-capped, return-bomb-safe, `nonReentrant`; any failure routes to redirect/claimable. |
| Recipient/filler revert (e.g. blacklist) | `_payout` falls back to a `claimable` ledger; settlement still completes. |

The contracts ship with **141 tests** — pure-library fuzzing of the pricing curve, full CCTP and OFT lifecycles, the executor's routed/forward/hook modes, factory pool-isolation, adversarial races, 16k+-call invariants, gasless flows, destination-execution edge cases (reentrancy, return-bomb, gas-budget, atomic claw-back), gas benchmarks, and mainnet-fork checks against the real CCTP, USD₮0, Uniswap, Aave, and Permit2 contracts.

---

## Status & what's next

**This is a prototype. It is not audited.** It's deployed for demonstration with tight caps on transfer size, and the backend relayer holds a hot key — so the live demo runs on tiny amounts. With that said, the design is intentionally close to production-shaped, and the iteration points are known:

- **Circle Gateway relayer funding** — a designed-but-unbuilt path that lets a relayer keep a single *unified* USDC balance and mint destination inventory just-in-time before filling, eliminating pre-positioned inventory entirely. The design is front-run-safe by construction (the mint is bound to the relayer, and the fill pulls from `msg.sender`, so a copycat's transaction reverts and rolls back the mint).
- **Surplus routing** — currently always to the recipient; an intended point of flexibility.
- **More tokens and chains** — additive for OFTs (assign an id, add rows, deploy); a new chain means publishing a new registry version.
- **A live OFT demo** and, ultimately, an audit.

The philosophy won't change, though. fast-fill is a **thin, honest layer**: it makes a cross-chain transfer faster when someone's willing to help, it makes that help trustless and permissionless, and when no one helps it gets out of the way and lets the bridge do exactly what it always would. Speed when you can get it; safety, always.

---

## Try it and dig in

- **Interactive demo** — connect a wallet on Arbitrum / Optimism / Base, send a real (capped) transfer, and watch a relayer fill it in seconds.
- **`docs/ARCHITECTURE.md`** — the full design deep-dive, with every authentication flow and the complete security table.
- **`docs/PRICING.md`** & **`docs/GAS.md`** — the off-chain quote model and the gas methodology behind the charts above.
- **`DEMO.md`** — the complete mainnet transaction record for the run charted here.
- **`relayer/`** — the autonomous Rust relayer bot, if you want to run one yourself.

*fast-fill — instant when it can be, never worse than the bridge.*

---

## Cheap Parallel EVM Simulation for Searchers

> Three Rust crates that give MEV searchers a small runtime — a forked-EVM state cache, reactive oracle adapters, and AMM state — to run thousands of parallel liquidation simulations against real protocol bytecode without RPC round-trips.

- Canonical URL: https://blog.eggtech.io/posts/parallel-evm-simulation
- Markdown URL: https://blog.eggtech.io/posts/parallel-evm-simulation/index.md
- Published: 2026-06-30
- Updated: 2026-06-30
- Author: Kai Aldag
- Reading time: 6 min read
- Tags: mev, rust, crypto

---

MEV searchers spend most of their time asking a simple question in many slightly
different ways: if this state transition happens, which response is profitable?

The hard part is not a single EVM call. The hard part is running thousands of
stateful simulations while the world is moving underneath you. You need fresh
oracle prices, token balances, AMM reserves, concentrated-liquidity ticks,
protocol state, and enough execution context to evaluate competing bundles. You
also need to do it cheaply enough that the search loop can afford to be wrong
many times before it is right once.

I built three Rust crates around that problem:

- `evm-fork-cache`: a forked EVM state cache for fast local simulation.
- `evm-oracle-state`: reactive oracle adapters for turning price-feed events
  into local cache updates.
- `evm-amm-state`: AMM adapters for warming and quoting common venue types.

Together, they form a small searcher runtime: keep the subset of chain state you
care about warm, update it reactively from events, and run many local EVM
simulations in parallel without round-tripping to RPC in the hot loop.

## The Core: `evm-fork-cache`

`evm-fork-cache` is the base layer. It wraps a forked EVM database with local
state injection, snapshots, overlays, bundle execution, and targeted purging.

The design goal is to make the expensive part explicit. RPC is used during
warmup or reconciliation. Once the relevant state is warm, the search loop takes
a snapshot and fans out isolated overlays. Each worker can simulate a different
bundle against the same post-event state without refetching the world or
serializing on one mutable database.

That matters for searchers because most useful strategy evaluation is not one
call. It is a matrix:

- Which debt asset should I repay?
- Which collateral should I seize?
- How much should I cover?
- Should I route through a V2 pool, a V3 pool, Curve, or a multi-hop path?
- What happens after gas and protocol edge cases?

The crate is built to make that matrix cheap to evaluate.

## Reactive Oracles: `evm-oracle-state`

Oracle updates are often the trigger for an opportunity. A price feed moves, a
position crosses a health-factor boundary, and liquidation logic becomes live.

`evm-oracle-state` provides the bridge from on-chain oracle events into local EVM
state. It registers feeds, decodes relevant transmissions, computes the storage
updates implied by the event, and applies those updates to `evm-fork-cache`.

The important point is that this is not a mock price table next to the EVM. The
cache state itself is updated, so downstream protocol calls observe the same
storage they would observe on-chain. Aave's `getUserAccountData`, liquidation
validation, and execution all run against the changed local state.

The demo also shows why extension points matter. The historical Aave fixture uses
a WETH price source wrapped by a `DualAggregator` contract. It emits OCR2-shaped
events, but its storage layout differs from the standard Chainlink OCR2 layout.
The demo plugs in a tiny custom storage adapter for that wrapper rather than
special-casing the core cache.

## AMM State: `evm-amm-state`

Liquidating is only half of the search. The seized collateral still needs to be
unwound.

`evm-amm-state` supplies protocol adapters for warming and quoting AMM venues.
In this showcase, the same seized WETH amount is quoted through:

- Uniswap V2 WETH/USDT
- Uniswap V3 0.05% WETH/USDT
- Curve Tricrypto2

The point is not that these three venues are the only routes a real searcher
would evaluate. The point is that they have different state shapes and pricing
models: constant product reserves, concentrated liquidity, and Curve crypto-pool
math. Running them side by side demonstrates that the cache can support realistic
DeFi heterogeneity.

## The Showcase: A Historical Aave V3 Liquidation

The demo repository is `evm-liquidation-showcase`. It uses a historical Ethereum
mainnet Aave V3 liquidation:

- User: `0x6DAAfC55ad39970d5EBeA808DBA2e8A598945632`
- Pre-trigger block: `25232834`
- Oracle trigger block: `25232835`
- Liquidation block: `25232836`
- Reference transaction:
  `0xf19a03d68c9ffabb06f8e7b30db32e5aeefb5b235be0bdea6429050324142153`

At the pre-trigger block, Aave reports the account with health factor `1.004628`.
After the oracle movement, the account is below water at `0.998193`.

The demo then:

1. Warms the Aave account, oracle feeds, and AMM venues.
2. Replays the WETH oracle transmission through the reactive oracle runtime.
3. Reconciles the companion AAVE collateral source for the same trigger block.
4. Confirms the account is liquidatable.
5. Funds a synthetic searcher inside the local cache.
6. Runs liquidation candidates in parallel from one post-oracle snapshot.
7. Quotes the seized WETH through three AMM venue types.
8. Picks the best expected PnL.

The successful run currently selects the actual liquidation size routed through
Uniswap V3:

```text
candidate=actual route=Uniswap V3 seized=1.077825 WETH -> 2010.444319 USDT
repay=1925.651145 USDT, gas=2.572193 USDT, expected PnL=+82.220981
```

One candidate intentionally reverts:

```text
candidate 50% reverted: MustNotLeaveDust()
```

That is useful. It shows the engine is not just applying spreadsheet math around
Aave. It is actually executing protocol validation. Invalid candidates can fail
inside Aave while other candidates continue and compete on route quality.

## Why This Is Interesting

The technical bet behind these crates is that a searcher should not have to
choose between correctness and throughput.

Pure off-chain math is fast, but it tends to drift from protocol reality. Raw
RPC simulation is accurate, but too slow and too expensive for wide search. This
project sits in the middle: keep a local EVM cache close enough to chain state,
react to the events that matter, and use parallel snapshots to explore many
possible actions cheaply.

For DeFi search, that shape is powerful:

- Oracle events can update local protocol state immediately.
- AMM events can mark pools stale or update reserves locally.
- Candidate bundles can execute against real protocol bytecode.
- Worker threads can evaluate competing paths without shared mutable EVM state.
- The final ranking can include gas and route-specific unwind pricing.

This is the kind of infrastructure I want in a serious trading system: narrow
enough to be fast, faithful enough to catch protocol edge cases, and modular
enough to extend as new venues and triggers matter.

## What Comes Next

The natural next step is expanding adapters and making the warmup/reconciliation
surface more declarative:

- More oracle wrapper layouts.
- More AMM variants and multi-hop route generation.
- Event-driven AMM reserve updates, not only cold-started quotes.
- Better reusable fixtures for historical opportunity replay.
- Strategy-level harnesses that compare candidate bundles across collateral,
  debt asset, route, and gas assumptions.

The initial release is intentionally small, but the architecture is already
pointing at the full searcher loop: reactive state ingestion, cheap parallel EVM
simulation, and practical DeFi execution analysis.

---

## Systematic ETH Tail-Risk Trading on Polymarket

> A trading system that prices ETH strike markets on Polymarket against a Binance-derived move distribution, sizes exposure to an ETH risk budget, and runs the full discovery-to-settlement loop.

- Canonical URL: https://blog.eggtech.io/posts/eth-tail-risk-polymarket
- Markdown URL: https://blog.eggtech.io/posts/eth-tail-risk-polymarket/index.md
- Published: 2026-06-29
- Updated: 2026-06-29
- Author: Kai Aldag
- Reading time: 12 min read
- Tags: trading, quant, crypto

---

## Executive Summary

I built and deployed a Python trading system for ETH binary and barrier markets
on Polymarket. The system discovers active ETH strike markets, filters stale or
unfillable CLOB books, prices outcomes using Binance-derived empirical move
distributions, sizes exposure against an ETH-denominated risk budget, and
reconciles orders, fills, and settlements into a local ledger.

The trading hypothesis is that short-window ETH upside tails are often priced
above realized path probability. The strategy buys NO on selected upside strike
markets and optionally uses expected profits to fund downside YES hedges,
creating a portfolio-aware short digital / short one-touch overlay on a long ETH
book.

This is not a passive yield product. It is a market-structure and execution
case study: identify a venue-specific mispricing, model it, size it against
portfolio constraints, and run the trading loop with auditability.

## Market Structure

ETH holders have a yield problem. Staking and lending returns are modest, while
many higher-yield alternatives change the exposure profile through restaking
risk, LP inventory risk, leverage, or opaque structured products.

I wanted a more explicit expression. I am structurally long ETH, but I believe
ETH usually grinds upward over weeks rather than repeatedly gapping through far
out-of-the-money strikes over short windows. Prediction markets make that view
tradable through listed ETH strike contracts:

- terminal digital markets, such as "ETH above X at expiry";
- barrier / one-touch markets, such as "ETH reaches X before expiry";
- downside strike markets, such as "ETH dips to X before expiry."

Economically, the upside NO book behaves less like a vanilla covered call and
more like selling cash-settled digital or one-touch exposure against long ETH
inventory. That distinction matters because the loss profile is discontinuous
around strikes and, for barrier markets, path-dependent.

## Trading Rule

The discretionary view becomes parameters:

```json
{
  "upside_no": [
    { "pct": 0.10, "weight": 0.2, "max_days_to_expiry": 2 },
    { "pct": 0.20, "weight": 0.3, "min_days_to_expiry": 20 },
    { "pct": 0.30, "weight": 0.5, "min_days_to_expiry": 20 }
  ],
  "downside_yes": [
    { "pct": -0.05, "weight": 0.6, "min_days_to_expiry": 20 },
    { "pct": -0.10, "weight": 0.4, "min_days_to_expiry": 20 }
  ],
  "hedge_buffer": 0.8,
  "downside_funding_fraction": 0.5
}
```

The bot never hardcodes absolute strikes. It derives targets from current ETH
spot, then maps those targets onto whatever strike grid Polymarket actually
lists. If the target lies between two fillable strikes, the bot linearly splits
the bucket toward the closer strike. This keeps the strategy tied to the shape
of the view rather than to one hand-picked market.

Within each strike and expiry window, APR is only a tie-breaker. The strike is
chosen by target proximity and fillability. Price quality and model edge decide
whether the opportunity is worth trading.

## Pricing Layer

Headline APR is not enough. A very short-dated binary can show a large
annualized return simply because time-to-expiry is small. The relevant question
is whether the market-implied probability is wrong relative to ETH's path
distribution.

The pricing layer uses Binance ETH market data to estimate:

- terminal move distributions for digital "above/below at expiry" markets;
- intraperiod maximum and minimum excursions for barrier markets;
- horizon-specific jump behavior for 24-48 hour markets;
- regime-sensitive realized volatility so old low-volatility data does not
  dominate current-risk estimates.

For each outcome, the model converts strike, direction, market style, and
time-to-expiry into a probability of winning. It then compares that probability
with the market-implied price:

```text
edge = model_probability_of_win - market_price
expected_return_on_cost = edge / market_price
```

For NO positions, the win probability is the probability that the upside
condition does not occur. This lets the bot separate "high APR because the
market is genuinely risky" from "high APR because the market is overpaying for
an unlikely tail."

## Data and Venue Handling

The market-data work ended up being as important as the model. Prediction-market
feeds are messy in ways that create false edge if handled naively:

- settled markets can remain in "active" event feeds;
- some Gamma markets have no CLOB book;
- stale asks can imply impossible returns;
- event slugs contain years and other numbers that can confuse strike parsers;
- multi-strike questions can look tradable unless rejected explicitly;
- barrier and terminal markets need different probability and hedge treatment.

The planner validates every market through the CLOB, requires usable top-of-book
prices, rejects stale or unparseable markets, parses direction from the question
text rather than the slug, and keeps market style explicit. Expected 404s from
dead books are summarized rather than treated as hard failures.

## Risk Model

The sizing invariant is the core risk control:

```text
total upside NO cost <= hedge_buffer * ETH_appreciation_at_highest_resolved_strike
```

For terminal "above at expiry" markets, the hedge relationship is relatively
clean: if the NO loses, ETH is above the strike at the same time the market
settles, so the ETH inventory should have appreciated enough to fund the loss,
subject to the hedge buffer.

For barrier markets, the risk is different. ETH can touch the strike, lock in a
NO loss, and then mean-revert before the portfolio is marked or hedged. The bot
therefore treats barrier NO exposure as path-dependent short gamma. In practice,
that means barrier markets should be sized more conservatively, paired with an
automatic ETH/perp hedge when spot approaches the barrier, or excluded when the
available hedge capacity assumes terminal settlement.

Downside YES bets are funded from expected NO profits, not from the primary
upside-risk budget. This shifts the exposure from purely linear long ETH toward
a long ETH book with dampening rebates around selected downside strikes.

### Funding Controls

The trading capital is separated from the ETH inventory. The ETH book defines
hedge capacity; the deployable dollar float defines what can actually be put to
work today.

The Aave monitoring layer I am building around the bot tracks collateral value,
debt, health factor, and LTV. If funding risk starts to dominate, the bot can
pause new rolls, reserve profits for debt service, or reduce exposure. In a
selloff, the upside NO book should be moving toward a winning outcome, but that
does not remove liquidation risk. The funding monitor is what prevents a good
relative-value trade from becoming a bad balance-sheet trade.

There is also a long-duration allocation rule. If annual ETH strike markets are
available at rates that clear Aave borrow cost plus liquidity, slippage, and
tail-risk buffers, the bot can allocate a small portion of excess profits to
far-out annual NO markets. Otherwise, the capital stays reserved for debt
service, rolling daily markets, or ETH accumulation.

## Implementation Snapshot

| Area | Current implementation |
|---|---|
| Language | Python |
| Market data | Polymarket Gamma + CLOB, Binance ETH candles |
| Pricing | Empirical terminal and excursion distributions |
| Risk | ETH-equivalent inventory, hedge buffer, market-style controls |
| Funding | Aave LTV / health-factor monitor in progress |
| Storage | SQLite order, fill, settlement, and capital ledger |
| Execution | Plan generation, capital scaling, signer/funder-compatible flow |
| Accounting | Fee and rebate treatment included in realized edge |
| Next | Depth-aware sizing, Monte Carlo, barrier-specific hedging |

The bot is built as a trading system rather than a one-off script. Plans are
serialized with stable leg IDs, every order intent is recorded, fills and
settlements are reconciled back into the ledger, and private-key handling uses
an encrypted Ethereum V3 keystore rather than a raw key. Execution accounting
also includes referral/rebate treatment so edge is measured net of avoidable
venue costs.

## Exposure Surface

The strategy does not remove ETH beta. It reshapes it.

| ETH path | ETH inventory | Prediction-market overlay | Combined effect |
|---|---:|---:|---|
| Slow grind up | Gains | Upside NOs likely win | Carry plus ETH beta |
| Sharp upside through terminal strikes | Gains a lot | NOs lose | Designed to remain positive after hedge buffer |
| Barrier touch then reversal | May give back gains | NO can lose permanently | Requires smaller sizing or dynamic hedge |
| Flat | Mostly unchanged | NOs likely win | Carry without needing ETH appreciation |
| Moderate down month | Loses | NOs win; downside YES may win | Drawdown is partially dampened |
| Severe selloff | Loses materially | NOs win | Helps service debt, but funding risk must be controlled |

The chart below is a synthetic 25 WETH-equivalent planner run. It is normalized
to return on ETH notional, before depth, slippage, fees, and funding. The point
is the shape: the prediction-market overlay adds carry in flat, down, and
moderate-up paths while preserving positive combined exposure through the
modeled terminal upside-loss region.

![Normalized ETH tail-risk payoff](/images/eth-tail-risk-polymarket/case-study-exposure-curve.png)

The full dashboard output also includes per-leg allocation, settlement timeline,
and a time-by-price heatmap: [the full dashboard render](/images/eth-tail-risk-polymarket/case-study-hypothetical-dashboard.png).

## Failure Modes

| Failure mode | Why it matters | Mitigation |
|---|---|---|
| Barrier touch then reversal | NO loses but ETH hedge may not remain profitable | Separate barrier sizing; dynamic spot/perp hedge near barrier |
| Liquidity disappears | Model edge may be untradeable at size | Walk CLOB depth before sizing; cap order size by fill quality |
| Stale or misparsed markets | False edge from bad data | Market-state filters, CLOB validation, question-text parser |
| Fat tails / volatility regime shift | Historical distribution can underprice jump risk | Regime filters, wider buffers, lower exposure around events |
| Aave funding risk | Stablecoin debt can dominate strategy risk in selloffs | Low LTV, pause rules, health-factor triggers, profit reservation |
| Settlement ambiguity | Market condition may not match modeled condition | Explicit market-style parser and manual override path |
| Capacity limits | High APR can vanish at meaningful size | Report executable depth, fill quality, and capacity estimate |

One stress case makes the barrier risk concrete: ETH trades from spot to a +30%
barrier intramonth, triggers one-touch NO losses, and then mean-reverts before
month-end. Terminal ETH appreciation would no longer fund the barrier loss. The
system has to either hedge dynamically near the barrier or size that exposure as
path-dependent short gamma from the start.

## Results

I separate results into live-system validation and historical simulation.

### Live Pilot

The live pilot is not evidence of a production Sharpe yet. It is evidence that
the full trading loop works: market discovery, plan generation, order placement,
fills, settlement reconciliation, capital recycling, and ledger accounting.

| Metric | Value |
|---|---:|
| Settled positions | 32 |
| Wins / losses | 32 / 0 |
| Win rate | 100.00% |
| Return on settled filled cost | +0.80% |
| Capital-weighted annualized turnover return | +337.88% |

I do not treat the annualized figure as a capacity estimate. It is shown only to
illustrate short-duration capital turnover in the daily roll. The production
questions are executable depth, adverse selection, fees, slippage, settlement
latency, and capacity.

### Monthly Simulator, February-May 2026

The monthly simulator applies the same hedge math to the February-May 2026
monthly book. It excludes daily markets and reports results before slippage,
fees, and depth constraints, so I treat it as directional evidence rather than a
production backtest.

| Month | ETH spot move | Polymarket ROI on cost | Combined return on ETH notional | Wins / legs |
|---|---:|---:|---:|---:|
| February 2026 | +0.93% | +72.41% | +21.24% | 5 / 6 |
| March 2026 | +7.12% | +23.35% | +13.49% | 4 / 8 |
| April 2026 | +7.18% | +1.40% | +7.52% | 3 / 8 |
| May 2026 | -11.17% | +99.97% | +18.64% | 8 / 8 |
| Aggregate | n/a | +52.90% | +60.80% | 20 / 30 |

The simulator produced `+52.90%` Polymarket ROI over four months on cost staked,
or roughly `+158.71%` annualized on deployed prediction-market capital. On the
average ETH notional in the simulator, the incremental overlay return was about
`+58.04%` over four months, or roughly `+174.12%` simple annualized.

May is the cleanest example of the intended exposure. ETH sold off, but the
prediction-market book more than offset the inventory drawdown, leaving the
combined book positive for the month. That is the target behavior: stay long ETH
while using the prediction-market overlay to harvest tail-risk mispricings and
dampen adverse paths.

### Capacity and Net Returns

The headline returns are not the same thing as scalable edge. A production
version has to report expected return net of spread, fees, rebates, slippage,
funding, and failed-fill probability. The next execution layer sizes from CLOB
depth, computes weighted-average entry price, and reports capacity as a first
class output rather than assuming top-of-book liquidity is available at size.

## Next Iteration

The next version focuses on making theoretical edge closer to executable edge:

- integrate the Binance pricing model directly into execution thresholds;
- walk CLOB depth and size by expected fill quality;
- separate barrier and terminal markets in both pricing and hedge logic;
- add path-dependent Monte Carlo for daily rolls plus monthly books;
- finish Aave debt-service automation and annual-market allocation rules;
- report capacity, slippage, fee/rebate impact, and mark-to-market drawdown.

## Why This Is Relevant

This is the kind of work I care about: finding fragmented or under-modeled
crypto market structure, turning it into a quantified strategy, building the
infrastructure to trade it safely, and improving the system as real fills expose
the difference between theoretical and executable edge.

The project required trading judgment, market-data hygiene, probability
modeling, portfolio-aware risk sizing, execution accounting, and operational
discipline. That combination is what makes the case study valuable: it is not
just a bot, and it is not just a backtest. It is a concrete example of taking an
edge from idea to live trading system.

---

## Single Sign: One Visible Signature, Many Verifiable Digests

> Aggregate many signature requests into one visible message the user signs once, then use RISC Zero proofs to show any individual EIP-712/191 digest came from that signed data — ERC-1271-compatible, so protocols validate digests with no extra popups.

- Canonical URL: https://blog.eggtech.io/posts/single-sign
- Markdown URL: https://blog.eggtech.io/posts/single-sign/index.md
- Published: 2025-10-21
- Updated: 2025-10-21
- Author: Kai Aldag
- Reading time: 10 min read
- Tags: zk, ethereum, crypto

### Referenced Links

- [GitHub](https://github.com/KaiCode2/single-sign)

---

Crypto signing UX has a simple problem: the more useful an interaction becomes, the more signatures it tends to require.

A swap might need an approval. A cross-chain action might need multiple permits. A smart account might need a typed authorization, a personal-message confirmation, and one or more protocol-specific payloads. Each request is individually reasonable, but the user experience turns into a stack of wallet popups.

Single Sign changes the shape of that flow.

Instead of asking the user to sign every request one by one, Single Sign aggregates many raw signature requests into one fully visible message. The user signs that aggregate once. Later, anyone can prove that a specific digest came from a slice of the originally signed data, without asking the user for another signature.

The result is:

- One user-visible signing step.
- Many downstream EIP-712, EIP-191, or other signable digests.
- A cryptographic proof that each digest existed inside the signed aggregate.
- An ERC-1271-compatible contract surface for protocols that expect signature validation.

The current repository demonstrates this with EIP-712 typed data, EIP-191 personal signing over the aggregate, RISC Zero proofs, and an ERC-1271 `SingleSign` contract.

## The Core Idea

Single Sign separates the user authorization from the later digest verification.

Normally, a protocol asks the user to sign exactly the digest it wants to verify. If there are ten protocol actions, there are ten signature prompts.

Single Sign asks the user to sign one readable aggregate instead:

```text
request_0 || request_1 || request_2 || ... || request_n
```

Each `request_i` is still the raw material needed to reconstruct a normal protocol digest. For EIP-712, that means the typed-data JSON containing `domain`, `types`, `primaryType`, and `message`. For EIP-191, it can be the personal-message payload. For another signable format, it can be any deterministic payload whose digest rules are known to the verifier program.

The important design choice is that the user signs the actual aggregate bytes, not an opaque Merkle root. The signature is over the visible content.

```mermaid
flowchart LR
    A["Raw sign request 0<br/>EIP-712 Permit2"] --> D["Visible aggregate message"]
    B["Raw sign request 1<br/>EIP-712 order"] --> D
    C["Raw sign request 2<br/>EIP-191 attestation"] --> D
    D --> E["User signs once"]
    E --> F["Aggregate signature"]
    F --> G["Many later digest proofs"]
```

That makes the signing moment legible. The user can see the actual requests being authorized, in order, before producing one signature.

## From Many Requests To One Message

The aggregation step has to be deterministic. Every byte matters.

In the current implementation, the host takes multiple EIP-712 typed-data JSON objects, compacts them, concatenates them, and asks the user to sign the resulting byte string using EIP-191 personal-message semantics.

Conceptually:

```text
compact(request_0) = {"domain":...,"types":...,"primaryType":"PermitTransferFrom","message":...}
compact(request_1) = {"domain":...,"types":...,"primaryType":"PermitTransferFrom","message":...}
compact(request_2) = {"domain":...,"types":...,"primaryType":"PermitTransferFrom","message":...}

aggregate = compact(request_0) || compact(request_1) || compact(request_2)
signature = personal_sign(aggregate)
```

The host also records the byte ranges for each original request:

```text
aggregate bytes
0                                                                  len
|------------------|------------------|-----------------------------|
 request_0          request_1          request_2
 [start_0,end_0)    [start_1,end_1)    [start_2,end_2)
```

In the repo, `common::find_concatenated_json_ranges` finds these `[start, end)` ranges by scanning the concatenated JSON string, tracking brace depth, and ignoring braces inside strings. The prover later uses one of these ranges to identify the slice whose digest should be reconstructed.

```mermaid
flowchart TB
    subgraph Requests["Original requests"]
        R0["request_0<br/>typed-data JSON"]
        R1["request_1<br/>typed-data JSON"]
        R2["request_2<br/>typed-data JSON"]
    end

    R0 --> C0["Compact bytes"]
    R1 --> C1["Compact bytes"]
    R2 --> C2["Compact bytes"]

    C0 --> AGG["Concatenate exact bytes"]
    C1 --> AGG
    C2 --> AGG

    AGG --> SIGN["EIP-191 sign aggregate"]
    SIGN --> SIG["One aggregate signature"]

    AGG --> RANGE["Range table<br/>[start,end) per request"]
```

This gives Single Sign two pieces of evidence:

- The aggregate signature proves the signer authorized the whole visible batch.
- The byte range proves where a particular request lives inside that batch.

## Proving A Digest Exists In The Signed Data

Once the user has signed the aggregate, downstream protocols do not need to receive another user signature. They need evidence that the digest they care about came from a request inside the signed aggregate.

Single Sign proves that with a zkVM program.

For one digest, the host provides the guest:

- `signer`: the EOA expected to have signed the aggregate.
- `signature`: the one signature over the full aggregate.
- `typed_data_concat`: the aggregate bytes.
- `digest_range`: the `[start, end)` byte range for the request being proven.

Inside the guest, the program performs two checks:

1. Verify the aggregate signature against the full `typed_data_concat` bytes.
2. Slice `typed_data_concat[start..end]`, parse that slice, and recompute the digest.

For the current EIP-712 path, the guest parses the slice as typed data and computes the standard EIP-712 signing hash:

```text
digest = keccak256("\x19\x01" || domainSeparator || hashStruct(message))
```

The guest commits only:

```text
(signer, digest)
```

as public output.

```mermaid
sequenceDiagram
    participant Host
    participant Guest as RISC Zero guest
    participant Verifier

    Host->>Guest: signer
    Host->>Guest: aggregate signature
    Host->>Guest: aggregate bytes
    Host->>Guest: digest range [start,end)
    Guest->>Guest: Verify EIP-191 signature over full aggregate
    Guest->>Guest: Extract aggregate[start..end]
    Guest->>Guest: Recompute request digest
    Guest->>Host: Receipt with journal (signer, digest)
    Host->>Verifier: Receipt, image ID, journal
    Verifier->>Verifier: Verify proof
    Verifier-->>Host: digest existed in signed aggregate
```

The proof binds three facts together:

- This signer produced a valid signature over the aggregate.
- This slice is part of that exact aggregate.
- This digest is the digest obtained by applying the expected digest rules to that slice.

That is the core Single Sign claim: a digest can be used later because it is proven to exist inside data the user already signed.

## Why Not Just Sign A Merkle Root?

Merkle trees are useful, but they make the user's signing moment worse.

If the wallet shows a Merkle root, the user sees a hash. The actual requests are somewhere else. The authorization is compact, but it is not naturally human-readable.

Single Sign optimizes for a different property: the signed preimage is visible.

The aggregate can be displayed as the actual list of typed-data requests, personal messages, and app-specific payloads. The later proof is what compresses the verification path, not the user-facing authorization.

```mermaid
flowchart LR
    subgraph RootFlow["Root-based batching"]
        A1["Requests"] --> A2["Merkle root"]
        A2 --> A3["User signs hash"]
        A3 --> A4["Needs external context to understand"]
    end

    subgraph SingleSignFlow["Single Sign batching"]
        B1["Requests"] --> B2["Visible aggregate"]
        B2 --> B3["User signs readable bytes"]
        B3 --> B4["Proof compresses later verification"]
    end
```

The tradeoff is deliberate. Single Sign keeps the user's signature attached to the content itself, then uses zero-knowledge proofs to make later per-digest validation compact and protocol-compatible.

## EIP-712, EIP-191, And Other Signable Data

The repo starts with EIP-712 because it is the most common format for structured protocol permissions. Permit2 is a good example: each permit has a typed domain, typed fields, a nonce, a deadline, a spender, and token permissions. Those fields are exactly what the user should be able to inspect.

But the model is broader than EIP-712.

A Single Sign aggregate can contain records for different signable formats as long as each record has deterministic digest rules:

```text
record_0: EIP-712 typed data -> eip712_signing_hash(record_0)
record_1: EIP-191 message    -> personal_message_hash(record_1)
record_2: Raw bytes          -> keccak256(record_2)
record_3: App-specific data  -> app_defined_hash(record_3)
```

In production, a mixed-format aggregate should include explicit type tags or an envelope around each request so the verifier knows which digest function to apply:

```json
{
  "kind": "eip712",
  "payload": {
    "domain": {},
    "types": {},
    "primaryType": "PermitTransferFrom",
    "message": {}
  }
}
```

The current code path demonstrates the EIP-712 version of this idea. The same guest architecture can be extended with additional digest adapters for EIP-191 personal messages, raw hashes, transaction intents, account-abstraction user operations, or protocol-specific authorization formats.

```mermaid
flowchart TB
    Slice["Slice from signed aggregate"] --> Kind{"Digest kind"}
    Kind -->|"EIP-712"| E712["Parse typed data<br/>Compute EIP-712 hash"]
    Kind -->|"EIP-191"| E191["Apply personal-message prefix<br/>Compute message hash"]
    Kind -->|"Raw32"| Raw["Use 32-byte digest directly"]
    Kind -->|"Custom"| Custom["Run app-specific digest rules"]
    E712 --> Out["Public journal<br/>(signer, digest)"]
    E191 --> Out
    Raw --> Out
    Custom --> Out
```

The aggregation layer stays the same. Only the digest function changes.

## How The Contract Uses The Proof

On-chain, Single Sign exposes the familiar ERC-1271 validation shape:

```solidity
function isValidSignature(bytes32 hash, bytes calldata signature)
    external
    view
    returns (bytes4)
```

The `hash` is the digest a protocol wants to validate. The `signature` calldata is the RISC Zero seal proving that `(owner, hash)` was committed by the guest.

The contract builds the expected journal:

```solidity
bytes memory journal = abi.encode(owner(), hash);
```

Then it asks the RISC Zero verifier to verify the proof against the compiled guest image ID. If verification succeeds, `isValidSignature` returns the ERC-1271 magic value. If verification fails, it returns `0x00000000`.

```mermaid
flowchart LR
    P["Protocol asks<br/>isValidSignature(digest, proof)"] --> S["SingleSign contract"]
    S --> J["Build journal<br/>abi.encode(owner, digest)"]
    J --> V["RISC Zero verifier"]
    V -->|"valid proof for image ID"| OK["Return ERC-1271 magic value"]
    V -->|"invalid proof"| NO["Return 0x00000000"]
```

To the protocol, this looks like signature validation. Under the hood, the "signature" is a proof that the digest was included in one larger signature the user already made.

## The End-To-End Flow

Putting it all together:

```mermaid
flowchart TB
    Dapp["Dapps / protocols<br/>prepare sign requests"] --> Normalize["Normalize and compact<br/>deterministic bytes"]
    Normalize --> Aggregate["Concatenate into one<br/>visible aggregate"]
    Aggregate --> Wallet["Wallet displays aggregate<br/>user signs once"]
    Wallet --> Sig["Aggregate EIP-191 signature"]

    Aggregate --> Ranges["Compute byte ranges<br/>for each request"]
    Sig --> Prover["Prover"]
    Ranges --> Prover
    Prover --> Guest["zkVM guest verifies signature<br/>and recomputes selected digest"]
    Guest --> Receipt["Receipt / proof<br/>journal: (signer, digest)"]
    Receipt --> Contract["ERC-1271 SingleSign contract"]
    Contract --> Protocol["Protocol accepts digest<br/>as signed by account"]
```

This is the main user experience improvement:

1. The user sees one aggregate.
2. The user signs once.
3. Each protocol can still verify the exact digest it expects.
4. The proof connects that digest back to the signed aggregate.

## What This Unlocks

Single Sign is useful anywhere a product needs many authorizations but should not force the user through many signature prompts.

Examples include:

- Batch Permit2 approvals.
- Multi-step trading or routing intents.
- Cross-chain actions with one authorization per domain.
- Smart-account sessions where one visible approval unlocks several bounded actions.
- Protocol workflows that need ERC-1271 compatibility but want a better signing UX.

The key is that Single Sign does not ask protocols to stop caring about their own digest format. A Permit2 digest can remain a Permit2 digest. An EIP-191 attestation can remain an EIP-191 attestation. A custom protocol hash can remain custom.

Single Sign simply proves that each digest came from a piece of data the user already saw and signed.

## The Principle

Users should not have to choose between safety and flow.

Opaque batching can improve flow while making the signing moment harder to understand. Repeated signatures can preserve protocol-level clarity while making the product painful to use.

Single Sign aims for the middle:

- Sign the visible content once.
- Prove individual digest inclusion later.
- Let existing protocols verify the digest they already understand.

One signature. Many requests. Fully visible authorization. Verifiable digest inclusion.

---

## Verifiable NFT Composition from IPFS Token URIs

> A RISC Zero zkVM app that proves an NFT's off-chain IPFS metadata matches its on-chain tokenURI, then composes verified NFTs into new ones — our Best ZKVM Application winner at ZKHack Montreal.

- Canonical URL: https://blog.eggtech.io/posts/verifiable-nft-composition
- Markdown URL: https://blog.eggtech.io/posts/verifiable-nft-composition/index.md
- Published: 2024-10-15
- Updated: 2024-10-15
- Author: Kai Aldag
- Reading time: 10 min read
- Tags: zk, nfts, crypto

### Referenced Links

- [Devfolio](https://devfolio.co/projects/zkompose-77da)
- [GitHub](https://github.com/KaiCode2/ipfs-risc0)

---

At ZKHack Montreal, this project won the **Best ZKVM Application** bounty from **RISC0**. The idea we explored is simple to describe and surprisingly powerful: what if an NFT's `tokenURI` could become a trustless input to new applications, not just a pointer that frontends render?

Most NFT metadata lives off-chain. On-chain, the contract usually publishes a URI such as:

```text
ipfs://Qm...
```

That URI is an IPFS content identifier. It commits to bytes, but smart contracts generally cannot afford to fetch the bytes, parse JSON, validate the schema, and run application logic over it. As a result, anything interesting that depends on metadata tends to move into trusted servers, indexers, or frontends.

We built a different path: a RISC Zero zkVM application that proves a supplied metadata object is exactly the content committed to by an on-chain IPFS token URI, then composes those verified objects into new NFT state.

In our demo, the NFTs are soccer player cards. Each player NFT publishes only an IPFS CID on-chain. The off-chain metadata contains player attributes like jersey number, tier, overall rating, speed, shooting, passing, dribbling, defense, physicality, and goal tending. The system proves that a claimed `Player` object really hashes back to the CID published by the ERC-721 contract. Once that fact is proven, the player metadata can be used inside another proof to build higher-level assets, such as a team NFT.

The important part is the trust model: the downstream application does not need to trust the caller, an API, an indexer, or a metadata server. It trusts the on-chain `tokenURI` and a zkVM proof.

## Why This Matters

NFT metadata is a huge amount of latent application state. It can encode game stats, attributes, inventory, generative traits, identity claims, membership properties, or arbitrary JSON. But because that data usually sits behind IPFS URIs, most contracts treat it as opaque.

That limits composability. You can transfer the NFT, check ownership, and maybe read a URI, but you cannot easily say:

- "Build a team from these verified player cards."
- "Upgrade this NFT only if its existing metadata has a qualifying trait."
- "Create a derivative asset whose attributes are computed from several source NFTs."
- "Migrate metadata to a new format without trusting a centralized migration service."
- "Prove that an edited IPFS object preserves selected fields from the original object."

RISC Zero changes the boundary. The expensive and awkward work happens inside the zkVM: decoding data, recomputing CIDs, checking application rules, and producing new outputs. Ethereum only verifies a succinct proof and consumes the verified journal.

## The Core Mechanism

The project has four main pieces:

1. A shared Rust metadata and CID library.
2. A player-verification zkVM guest.
3. A team-composition zkVM guest.
4. Solidity contracts that publish and verify the resulting commitments.

Here is the high-level split between the chain, the publisher, and the zkVM:

```mermaid
flowchart LR
    subgraph Chain["On-chain state and verification"]
        Players["Players ERC-721<br/>ownerOf(tokenId)<br/>tokenURI(tokenId)"]
        Team["Team contract<br/>buildTeam(...)"]
        Verifier["RISC0 verifier<br/>checks seal and image ID"]
    end

    subgraph Host["Off-chain host"]
        Metadata["IPFS metadata bytes<br/>Player JSON"]
        Publisher["Publisher CLI<br/>preflight, prove, submit"]
        PlayerReceipt["Player proof receipt<br/>owner + state commitment"]
        TeamProof["Team proof<br/>seal + journal"]
    end

    subgraph Zkvm["RISC0 zkVM"]
        VerifyCid["verify_cid guest<br/>prove metadata matches tokenURI"]
        MakeTeam["make_team guest<br/>compose verified players"]
    end

    Metadata --> Publisher
    Publisher -->|"Steel preflight calls"| Players
    Players -->|"tokenURI + owner + EVM input"| Publisher
    Publisher -->|"Player JSON + tokenId + EVM input"| VerifyCid
    VerifyCid --> PlayerReceipt
    Publisher -->|"player receipts as assumptions"| MakeTeam
    MakeTeam --> TeamProof
    Publisher -->|"playerIds + teamURI + seal"| Team
    Team -->|"verify(seal, imageId, journalHash)"| Verifier
    Verifier -->|"valid proof"| Team
```

The sequence looks like this when someone builds a derived NFT from existing IPFS-backed NFTs:

```mermaid
sequenceDiagram
    actor Holder
    participant Publisher as Publisher CLI
    participant Players as Players ERC-721
    participant VerifyCid as RISC0 verify_cid guest
    participant MakeTeam as RISC0 make_team guest
    participant Team as Team contract
    participant Verifier as RISC0 verifier

    Holder->>Publisher: Provide player metadata and token IDs
    Publisher->>Players: Preflight ownerOf(tokenId) and tokenURI(tokenId)
    Players-->>Publisher: Return owner, URI, and EVM proof input
    Publisher->>VerifyCid: Prove Player JSON matches on-chain tokenURI
    VerifyCid-->>Publisher: Return receipt with owner and state commitment
    Publisher->>MakeTeam: Add player receipts as assumptions
    MakeTeam->>MakeTeam: Verify player proofs and run team rules
    MakeTeam-->>Publisher: Return team proof seal and public journal
    Publisher->>Team: Submit player IDs, team URI, and proof seal
    Team->>Verifier: Verify seal against MAKE_TEAM_ID and journal hash
    Verifier-->>Team: Accept or reject proof
```

And at the zkVM-program level, the two proving steps are:

```mermaid
flowchart TB
    subgraph VerifyProgram["verify_cid zkVM program"]
        A["Inputs<br/>Player JSON<br/>tokenId<br/>Steel EVM input"] --> B["Execute authenticated view calls<br/>ownerOf(tokenId)<br/>tokenURI(tokenId)"]
        B --> C["Serialize Player JSON"]
        C --> D["Compute UnixFS CID"]
        D --> E["Format as ipfs://CIDv0"]
        E --> F{"Computed URI equals<br/>on-chain tokenURI?"}
        F -->|"yes"| G["Commit journal<br/>state commitment + owner"]
        F -->|"no"| H["Abort proof"]
    end

    subgraph TeamProgram["make_team zkVM program"]
        I["Inputs<br/>owner<br/>players<br/>token IDs<br/>player proof receipts"] --> J["Verify each receipt<br/>against VERIFY_CID_ID"]
        J --> K{"Receipts prove same owner<br/>and committed chain state?"}
        K -->|"yes"| L["Apply team composition rules"]
        L --> M["Compute derived team metadata CID"]
        M --> N["Commit journal<br/>teamCID + playerIds + commitment"]
        K -->|"no"| O["Abort proof"]
    end

    G -->|"receipt used as assumption"| J
```

### 1. Recomputing IPFS CIDs in Rust

The common Rust crate defines a serializable `Player` metadata type and a `ComputeCid` trait. Given any serializable object, the code serializes it to JSON bytes and feeds those bytes through the same UnixFS-style CID construction used by IPFS.

That gives us two useful forms:

- The raw CID bytes, including the multihash prefix.
- The formatted URI string, `ipfs://<cid>`.

This is the bridge between ordinary NFT metadata and verifiable computation. A prover can present a JSON object to the zkVM, and the zkVM can independently derive the CID that object should have.

### 2. Reading the Real On-Chain Token URI

The player verification guest uses RISC Zero Steel to make authenticated Ethereum view calls inside the proof. It reads:

- `ownerOf(tokenId)`
- `tokenURI(tokenId)`

from the Player ERC-721 contract on Sepolia.

The guest then recomputes the expected IPFS URI from the supplied `Player` metadata and asserts that it exactly matches the on-chain `tokenURI`.

If the assertion passes, the proof journal commits to the Ethereum state commitment and the owner. That means a verifier learns: at the proven chain state, this player object is the content committed to by this token's published URI, and this address owned the token.

### 3. Composing Verified NFTs

The next step is composition. A team should not be built from arbitrary JSON that merely looks like player metadata. It should be built from player objects that have already been proven to correspond to real player NFTs.

RISC Zero proof composition gives us that structure. The team-building guest can accept player verification receipts as assumptions, verify those receipts inside the zkVM, and then run higher-level application logic over the verified player data.

In the soccer demo, that means composing player NFTs into a team. The same pattern generalizes to any rule system:

- Combine multiple source NFTs into one derived NFT.
- Enforce ownership or approval checks.
- Preserve selected metadata fields.
- Compute new attributes from old attributes.
- Produce a new IPFS CID for the derived object.

Instead of treating metadata as a frontend convention, the application can treat it as proven input.

### 4. Verifying the Result On-Chain

On-chain, the contracts use RISC Zero's Groth16 verifier interface. The generated image IDs bind each proof to a specific zkVM program:

- `VERIFY_CID_ID` identifies the player CID verification guest.
- `MAKE_TEAM_ID` identifies the team composition guest.

The Player contract stores compact CID commitments as `bytes32` values and reconstructs CIDv0 URIs by prepending the multihash prefix and Base58 encoding the result. That keeps on-chain storage small while still exposing standard `ipfs://...` token URIs.

The Team contract is the receiving side for the composed proof. It checks player approvals, verifies the RISC Zero seal against the team-building image ID, and only accepts the submitted team URI when the proof's journal matches the expected public output.

## What We Built

The repository contains an end-to-end prototype of verifiable IPFS-backed NFT composition:

- `common` defines the player metadata schema and CID derivation logic.
- `methods-player` contains the zkVM guest that proves a player object matches the on-chain IPFS `tokenURI`.
- `methods-team` contains the zkVM guest for composing verified player proofs into a team-building proof.
- `apps` contains a publisher CLI that preflights Ethereum calls with Steel, generates player proofs, passes them as assumptions into the team proof, and prepares the proof output for on-chain verification.
- `contracts` contains the ERC-721 player and team contracts, RISC Zero image ID integration, and CIDv0 URI formatting logic.

The demo domain is intentionally concrete. Soccer cards make the value easy to see: a player card has metadata-rich stats, and a team is a meaningful composition of multiple players. But the actual primitive is broader than sports or games.

We proved that an NFT's off-chain metadata can become a verifiable input to another application, as long as the original token publishes an IPFS CID on-chain.

## Trustlessly Modifying IPFS URIs

One way to describe this project is "trustless IPFS URI modification."

That does not mean changing the contents behind an existing CID. IPFS CIDs are content-addressed, so changing the bytes necessarily creates a new CID. Instead, the system lets anyone prove a valid transformation:

```text
old on-chain tokenURI -> verified old metadata -> application rule -> new metadata -> new IPFS CID
```

The proof can show that the new URI was derived correctly from the old one according to a known program.

That unlocks a useful design pattern for NFT applications:

1. Read the authoritative source URI from an existing on-chain NFT.
2. Provide the corresponding IPFS content to the zkVM.
3. Recompute the CID and prove it matches the source URI.
4. Apply deterministic transformation rules.
5. Compute the new CID.
6. Publish or accept the new URI on-chain only if the proof verifies.

No trusted backend has to certify the transformation. No indexer has to be treated as an oracle. No contract has to parse arbitrary JSON or implement IPFS hashing in Solidity. The zkVM handles the heavy computation, and the chain verifies the result.

## Where This Can Go

This pattern is useful anywhere IPFS metadata is already the source of truth but is too expensive or inconvenient for smart contracts to inspect directly.

Games can use it to craft items, combine characters, update stats, or enforce progression rules based on existing NFT metadata. NFT collections can use it for trustless migrations, where holders prove that a new metadata object faithfully preserves traits from an old collection while adding new fields. Creator tools can use it to generate derivative assets with provenance that is verifiable on-chain. DAOs and membership systems can use it to prove properties of off-chain metadata without exposing every detail in contract storage.

The broader point is that IPFS URIs do not have to be dead strings. With a zkVM, they can become composable commitments.

## The Takeaway

The project turns a common NFT pattern into a programmable primitive. An ERC-721 publishes an IPFS CID. A prover supplies the content. The zkVM recomputes the CID, proves it matches the on-chain URI, runs application logic over the verified content, and outputs a new commitment. Ethereum verifies the proof and can safely accept the result.

That is why this was a strong fit for the RISC0 bounty at ZKHack Montreal. It uses the zkVM for exactly the kind of work that is natural off-chain but hard on-chain: IPFS hashing, JSON-shaped application logic, authenticated chain reads, and proof composition.

The result is a system for composing NFTs in verifiably correct ways from the only thing the original NFT had to publish on-chain: its IPFS token URI.
