Skip to main content
marketId is the single join key that ties on-chain contract ⇄ SDK ⇄ subgraph together. It identifies a tradeable market — a (token0, token1, fee, duration) tuple — and it is what lets a Swap row (which stores no marketId) be joined back to its Market directory entry. Because three independent implementations in three different languages all reconstruct it, its byte layout is load-bearing: a one-byte width drift in any of them silently corrupts every join. This page documents the exact pre-image, the one non-obvious correctness subtlety (uint32 sign-extension), and the three-way parity tests that freeze all of it to a live on-chain ground truth.
A market is a (token0, token1, fee, duration) tuple, not a single position. Because MaxIL makes positions fungible within a market, the marketId is per-market — an MM quotes a whole market, never a single NFT. See two rails and the markets model.

The canonical formula

marketId=keccak256 ⁣(abi.encodePacked( token0, token1, fee, durationSeconds ))\texttt{marketId} = \mathrm{keccak256}\!\Big(\texttt{abi.encodePacked}(\,\text{token0},\ \text{token1},\ \text{fee},\ \text{durationSeconds}\,)\Big) with on-chain types (address, address, uint24, uint32) and token0 < token1 by address (so for the demo pair token0 = dWETH, token1 = dUSDC, the numéraire). The key word is abi.encodePacked: this is tight packing — no 32-byte ABI word padding. Each operand contributes exactly its on-chain width in big-endian bytes: That 47-byte pre-image is the whole game. If any implementation pads an operand to 32 bytes — for example by reaching for an ABI encoder that pads by default — the bytes shift and the keccak digest changes entirely. The contract registers a market under one id; the SDK and subgraph then compute a different id and find nothing to join to.
Limit → Why → Mitigation. The pre-image has no self-describing structure (it is raw concatenated bytes), so a width mistake produces a valid-looking but wrong 32-byte hash — failure is silent, not a revert. Why it matters: a wrong id doesn’t error, it just joins to nothing, so the Swap↔Market link goes quietly empty. Mitigation: the width contract is pinned by three-way parity tests — including an explicit packed.length == 47 assertion on-chain — so any drift fails CI before it ships.

Why duration is in the id

The duration is baked into the id on purpose. The same position geometry (token0, token1, fee) yields a different marketId per duration, and therefore a different price — the cvAMM publishes three prices (7d / 30d / 90d) for the same MaxIL, because all of the vol/time dependence lives in fairRate, an S-curve in σref2⋅T\sigma_{\text{ref}}^2 \cdot T. MaxIL itself is duration-independent; the marketId is what separates the three term points.

The three implementations, side by side

All three build the identical 47-byte tight-packed pre-image and keccak it. The only difference is the encoding primitive each language reaches for.
The subtlety is in the AssemblyScript port. The Graph’s ethereum.encode is the obvious tool — and it is wrong here, because it ABI-pads every operand to 32 bytes. The subgraph instead hand-rolls a fixed-width big-endian byte slice, uintBytesBE, and concatenates raw bytes at the on-chain widths (20 / 20 / 3 / 4). Viem’s encodePacked does the tight packing natively, so the SDK side is a direct call; AssemblyScript has no such primitive, hence the hand-rolled helper.
Subgraph — uintBytesBE

The uint32 sign-safety subtlety

This is the one place the AssemblyScript port could quietly diverge, and the helper is written specifically to defeat it. durationSeconds is a uint32 on-chain — its full domain is [0,232)[0, 2^{32}). But AssemblyScript has no unsigned 32-bit type in this path: the value arrives as an i32 after BigInt.toI32(). A duration ≥231\ge 2^{31} (about 68 years — outside any real market, but inside the type’s domain) therefore arrives negative, and when it widens to i64 for the shift it sign-extends: the high bits fill with ones. A naive byte extraction would then write those phantom sign bits into the pre-image and produce a hash that does not match the contract’s uint32 packing. uintBytesBE is immune by construction:
1

Only the low `width` bytes are emitted

The loop runs exactly width times (4 for a uint32), writing only bytes at shift offsets 0…(width−1)⋅80 \ldots (\text{width}-1)\cdot 8 — i.e. only the low 32 bits.
2

`& 0xff` masks each byte independently

Every extracted byte is masked with & 0xff, isolating exactly 8 bits regardless of the sign of value.
3

The sign bits live *above* `width` and are never written

Sign-extension only contaminates bits at position ≥32\ge 32. Those are never read by the loop and never masked into the output. So the 4-byte uint32 pre-image stays bit-for-bit correct across the entire on-chain domain, negative-looking i32 or not.
The same masking logic makes the uint24 fee slice (width = 3) correct too: only the low 3 bytes are written, so a fee value never bleeds extra bytes into the pre-image. The width contract — 20 / 20 / 3 / 4 — is enforced by the call sites uintBytesBE(fee, 3) and uintBytesBE(durationSeconds, 4).

The three-way, test-pinned parity

The 47-byte pre-image must stay identical across the contract, the SDK computeMarketId, and the subgraph deriveMarketId. That identity is not left to vigilance — it is frozen by two parity test suites that both pin to live Arbitrum Sepolia marketIds read from the on-chain address registry. Both files assert the same two vectors: The 7d vector is the demo market (marketId_fee500_7d in the registry); the 300s vector is the short lifecycle market used for the live create→settle proof. Pinning to addresses straight from the deployment registry means the tests fail the moment a field width drifts away from the deployed ground truth.
The subgraph’s deriveMarketId is the third corner of the triangle: it has no separate test file, but it is held to the same ground truth by construction — its hand-rolled 20/20/3/4 tight packing must reproduce the exact pre-image the other two sides pin. The on-chain packed.length == 47 assertion and the SDK’s “hand-built tight-packed keccak” check together freeze the width contract the subgraph relies on. Any field-width change that breaks the match fails the on-chain and SDK tests, which is the early-warning that the subgraph would otherwise have diverged.
The 0x67c4…9d69 demo marketId appears in three places that must agree: the deployment registry, the two parity test suites above, and every GraphQL query against the subgraph. It is the canonical example id used throughout these docs.

How the subgraph recovers the marketId from a SwapCreated

The marketId derivation is not just a registration concern — it is exercised on every fill, because the on-chain SwapRecord deliberately carries no marketId to stay compact. Both the SDK and the subgraph must reconstruct it from the swap’s stored fields. The subgraph does this inside handleSwapCreated:
1

Decode the position geometry

eth_call to NonfungiblePositionManager.positions(tokenId) to recover (token0, token1, fee) — three of the four pre-image fields.
2

Recover the duration

eth_call to InflexionCore.swaps(swapId) for expiry and createdAt, then durationSeconds = expiry − createdAt — the fourth field.
3

Derive the id

Call deriveMarketId(token0, token1, fee, durationSeconds), reusing the NPM contract bind rather than re-binding.
Both reads must succeed — the geometry decode and the swap-record read — because all four fields are required for the keccak. Each eth_call uses the try_* pattern so a revert degrades the field gracefully (the marketId stays unrecovered, durationSeconds stays null) instead of failing the whole handler. The SDK does the equivalent in resolveMarket: read NPM.positions(tokenId) for (token0, token1, fee), compute durationSeconds = Number(expiry − createdAt), call computeMarketId, then read back markets(marketId) to get the full config.
resolveMarket — recover the market for a swapId
This is why marketId parity is operationally load-bearing, not just at registration: every per-fill data row in the SDK and the subgraph re-derives the id to join the Swap to its Market. A divergence wouldn’t just orphan one market — it would orphan every fill.

Querying by marketId

Once derived, the marketId is the Market.id (its hex string) and the where filter for every per-market query. See the full surface in the subgraph integration page.
Market directory + swaps, keyed by marketId
Limit → Why → Mitigation. The subgraph deploy is pending until the Studio slug inflexion-arb-sepolia is created. Why: the rich moat events (QuoteFilled, SwapPriced) are coded but only began firing at the 2026-06-05 full-fresh redeploy, so the dataset genesis is the redeploy block 274081134 — indexing from there avoids scanning blocks that emit no moat data. Mitigation: the schema and manifest are finalised and the start block is known, so the indexer is ready to go the moment the slug exists; until then the SDK and API degrade history to a typed pending state, never an error.

See also

The subgraph

The full entity catalogue, the event-order fix, and the query surfaces that all join on marketId.

Decimals & units

The numéraire conventions — dUSDC 6-dec, dWETH 18, feeds 8, WAD 1e18 — that every value crossing this boundary obeys.

The markets model

Why a market is a (pair, fee, duration) tuple and why MaxIL makes positions fungible within one.

Deployments

The live Arbitrum Sepolia registry — the single source of truth the parity tests pin against.