# Mempool offer wire format — spec of record

This document is the source of truth for the offer-blob encoding. The
`/update-decoder` skill updates THIS FILE FIRST, then makes the code in
`packages/shared/src/offer-decoder/` match it.

## Where blobs come from

The Mempool contract (`Log` in the midnight repo's `periphery/Log.sol`) is a
pure broadcast channel: any call's calldata (≤ 1,000,000 bytes) is re-emitted
as a non-anonymous `Data(bytes)` event. Every broadcast offer batch therefore
appears as one `Data` log; the event's decoded `bytes` argument IS the wire
blob (no extra unwrapping).

## Wire format v1

```
0x
  {version   : 1 byte}          — 0x01
  {gzipLen   : 4 bytes, big-endian}
  {gzip stream, exactly gzipLen bytes}
  {optional opaque suffix ≤ 256 bytes}   — e.g. attribution tags; ignored
```

- The gzip stream decompresses to `abi.encode((Offer, bytes ratifierData)[])`.
- Item order is preserved and meaningful (display order).
- Each item carries its full take-side `ratifierData` inline — the blob a
  taker forwards verbatim to `Midnight.take`. EcrecoverRatifier path:
  `abi.encode(Signature, root, leafIndex, proof)`; SetterRatifier path:
  `abi.encode(root, leafIndex, proof)`; PriceRatifierV1 path:
  `abi.encode(root, leafIndex, proof, address allowedTaker)`; RateRatifierV1
  path: `abi.encode(root, leafIndex, proof, uint256 rate, address allowedTaker)`
  — the maker signs `rate` (WAD per second, simple interest) and NOT the tick,
  so a rate offer's tick is unsigned filler (`offer-decoder/ratifier-data.ts`
  decodes both V1 shapes; zero `allowedTaker` = anyone).
- Encoding is deterministic (gzip `mtime` zeroed).
- A declared `gzipLen` overrunning the blob, an unknown version byte, a suffix
  > 256 bytes, or anything over a resource limit below ⇒ undecodable (tagged,
  never thrown).
- Byte-compatible with `@morpho-dev/router`'s `Payload` codec; vendored from
  tenor-sdk (`mempool-encoding.ts`) — swap to the published tenor-sdk package
  when it exists.

## Resource limits

Anyone can broadcast, so every size in a blob is attacker-chosen and the
decoder is a hostile-input parser. Bounding the BYTES is not enough: ABI
offsets may **alias**, so a payload can declare hundreds of elements that all
point at one body carrying a big `callbackData`, and viem re-materialises that
blob once per element. A ~16 KB transaction can therefore describe gigabytes —
and the container is OOM-killed *mid-decode*, before the raw bytes are stored,
so rule 2 below never gets its chance: Ponder resumes from its checkpoint,
replays the same block and dies again. One transaction, permanent crash-loop.

Four caps close that, all in `encoding.ts` and all calibrated against measured
production traffic — the largest genuine broadcast is 60,649 bytes on chain,
inflating to 1,008,960 bytes across 563 offers, with the biggest `callbackData`
in 10,640 messages at 705 bytes:

| Limit | Value | Headroom over real traffic | Why |
| --- | --- | --- | --- |
| `MAX_DECOMPRESSED_BYTES` | 4 MiB | ~4x the 1,008,960-byte max | Caps the gzip bomb. DEFLATE reaches ~1032:1, so the old 16 MiB was reachable from a ~16 KB transaction. |
| `MAX_ITEMS_PER_PAYLOAD` | 4096 | ~7x the 563-offer max | Read straight out of the array's length word before viem builds one object graph per element. |
| `MAX_BLOB_BYTES` | 8 KiB | ~11x the 705-byte max | Per declared `callbackData` / `ratifierData`. |
| `MAX_DECLARED_BLOB_BYTES` | 2 MiB | ~4x the ~470 KB max | Sum of every item's declared blob lengths. |

The last one is the one that closes the aliasing hole, and it works by counting
each item's blobs **separately even when several items point at the same
bytes**. An honest payload contains each blob exactly once, so a declared sum
far above the payload's own size IS the aliasing signature. It — not
`MAX_DECOMPRESSED_BYTES` — is the binding limit on how much blob viem is ever
asked to materialise.

They are enforced by a structural pre-pass (`assertDecodableShape`) that runs
after decompression and before `decodeAbiParameters`. It walks the element
offsets down to each item's `callbackData` / `ratifierData` **length words**
without materialising any of them, and is itself bounded — every read is
bounds-checked, words too large to address the payload are rejected, and the
walk has a fixed read budget — so the guard cannot become the DoS it exists to
prevent.

Raising any of these is a deliberate decision, not a formality: re-measure real
traffic first, and remember that a limit tuned too tight tags genuine
broadcasts `undecodable` (recoverable — see rule 2) while one tuned too loose
risks the crash-loop (not recoverable without a code fix).

## The Offer struct

Layout is pinned to the deployed contract: `OFFER_PARAM` in
`offer-abi.ts` must equal the `offer` input of `Midnight.take` in the deployed
ABI — `offer-abi.test.ts` enforces this, so a protocol upgrade fails CI
instead of silently mis-decoding.

Fields: `market{chainId, midnight, loanToken, collateralParams[]{token, lltv,
liquidationCursor, oracle}, maturity, rcfThreshold, enterGate, liquidatorGate}`,
`buy`, `maker`, `start`, `expiry`, `tick`, `group`, `callback`,
`callbackData`, `receiverIfMakerIsSeller`, `ratifier`, `reduceOnly`,
`maxUnits` (uint128), `maxAssets` (uint128), `continuousFeeCap`.
Exactly one of maxUnits/maxAssets is nonzero (the offer's cap type).

## Derived hashes (the joins)

- `offerHash = keccak256(abi.encode(offer))` — equals `Take.offerHash`.
- `marketId = keccak256(0xff ++ market.midnight ++ uint256(0) ++
  keccak256(SSTORE2_PREFIX ++ abi.encode(market)))` — equals
  `Take.id_` / `MarketCreated.id_` (CREATE2/SSTORE2 derivation,
  `SSTORE2_PREFIX = 0x600b380380600b5f395ff3`).

Both are validated in `decoder.test.ts` against a REAL Base mempool blob and
the on-chain Take that settled one of its offers (fixtures in
`__fixtures__/`). Keep it that way: every format change adds a real-blob
fixture.

## Rules

1. The decoder NEVER throws — tagged results only (`decode.ts`).
2. Raw bytes are always stored by the indexer, so decoder upgrades re-decode
   all history automatically on the next deploy's re-index.
3. New versions branch on the version byte and KEEP decoding old versions —
   historic blobs are immutable.

## Version history

- **v1 (0x01)** — launch format, described above.
