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
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.
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 .
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.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 . But
AssemblyScript has no unsigned 32-bit type in this path: the value arrives as an
i32 after BigInt.toI32(). A duration (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 — 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 . 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 three-way, test-pinned parity
The 47-byte pre-image must stay identical across the contract, the SDKcomputeMarketId, 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.
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.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
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 theMarket.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
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.