Merkl Rewards
Reports an account's unclaimed Merkl rewards — held by the Distributor, valued from a proof-verified cache, per reward token and campaign share.
Reports an account's unclaimed Merkl rewards, per reward token.
The rewards are real and already on-chain: campaign funders transfer the reward tokens to Merkl's Distributor, which custodies them until each account claims its share. What is not on-chain is the split — Merkl's off-chain engine derives each account's cumulative entitlement per token from campaign rules and off-chain observation, publishes the resulting reward file, and pushes only a merkle root over it to the Distributor at intervals. The contract therefore holds the tokens, the current root, and claimed — how much each account has already withdrawn — but nothing that yields an account's entitlement on its own.
That is precisely the attested shape: the value is on-chain verifiable but not derivable. The family page covers the model, the trust boundaries, the introspection reads and the error taxonomy; this page covers only what is specific to Merkl.
Type: Attested adapter — the family's first member
protocolBrand / protocolId / protocolSubId: all three are
ProtocolIds.MERKL; Merkl has no product split to distinguishpositionId:
abi.encode(DISTRIBUTOR)— identical for every leg of every tokenpositionInstanceId: the campaign coordinate, where a breakdown was submitted
positionKind:
Rewards· protocolName():"Merkl"· labels:["Claimable Rewards"]Distributor:
0x3Ef3D8bA38EBe18DB133cEc108f4D14CE00Dd9Ae— Angle Labs, the same address on every supported chain
What it reports
A token's unclaimed total is its proof-verified cumulative minus the amount already claimed on-chain, with claimed read live from the Distributor on every read. One leg per cached reward token, or one per campaign share where a breakdown was submitted.
The live netting is what keeps the position from being counted twice. A claim does not destroy value — it moves it out of the Distributor and into the account's wallet, where the default adapter already reports it as an ordinary ERC-20 balance. Because Merkl's leaves carry a cumulative figure rather than an incremental one, subtracting claimed hands off exactly the settled portion and leaves the entitlement reported only while it is genuinely still outstanding.
Claimable Rewards
Rewards
false
Proven cumulative − claimed, in the reward token
Reading the legs — sum them, never de-duplicate
Every Merkl leg of one token shares an identical reconciliation key.
positionId is abi.encode(DISTRIBUTOR) — the same value for every leg of every token — and the campaign coordinate rides in positionInstanceId, which is excluded from the stable key. Legs of different tokens still separate, because the key includes balanceAsset.asset. Legs of the same token do not.
So a consumer that treats a repeated key as duplicate data and keeps one row under-reports that token by up to the per-campaign cap. Sum the legs.
Campaign attribution is display-grade, not audit-grade: the proof binds only the per-token total and the contract checks only that the shares sum to it. The per-token total — the sum of a token's legs — is the proof-backed figure. Treat the split as presentation.
campaignsOf(user, token) returns the cached CampaignShare[] directly, for a consumer that wants the breakdown without reassembling it from legs. Same standing as the legs' campaign coordinates: display and debugging, with the shares summing to the proof-verified per-token cumulative. Empty where the token was submitted untagged.
The proof shape
Merkl decodes the family's opaque bytes into Allocation[] — the same shape for the write path and the cache-free read:
The leaf proved is keccak256(abi.encode(account, token, cumulativeAmount)). Typed convenience wrappers exist for keeper ergonomics — submitProofs(user, allocations) and submitProofsBatch(users, allocations) — carrying the same atomicity as the family's submit and submitBatch.
Reading a position without the cache
previewPositions(account, abi.encode(allocations)) values an account straight from caller-supplied proof data — the same payload the write path takes. See reading without the cache for the semantics; the Merkl-specific part is only the payload shape above. As on the write path, the campaign split is not proof-bound — a preview built from fabricated shares that happen to sum correctly returns those shares.
Staleness and the dispute window
Each new root is published subject to a short dispute window — a window in which watchers verify the reward file and can post collateral to challenge it, and a challenged tree can be resolved back to its predecessor or revoked outright. getMerkleRoot() reflects that: during the window, or while a disputer is set, it returns the previous root. The root is therefore not monotonic — it can return to a value it held before, which is why the family's staleness check is a straight equality test and never an extrapolation.
Two operational consequences:
isAttestationLivecan flap for the same element —falseduring a rotation,trueagain if the root rolls back. A monitor that pages on a singlefalsewill page on dispute windows.A clear performed while the root is rolled back is not undone when the root returns.
clearAttestationis legitimate the moment an element reads stale, so an element cleared inside the window stays gone until the keeper resubmits. The reading is an under-count that self-heals on the next feed cycle.
Bounds
Two caps bound the per-account leg count, and it is their product a read budget must size against — not the token cap alone, because the campaign split is not proof-bound and so is influenceable by whoever submits. Read MAX_TOKENS_PER_USER and MAX_CAMPAIGNS_PER_TOKEN off the adapter rather than from a figure here; the product has already been tightened once. The family read maxAssetsPerAccount() returns the token cap, so a protocol-agnostic consumer needs no Merkl symbol to watch it.
Two consequences of the token cap that garbage collection, not configuration, resolves:
Under upsert semantics the cache accumulates every token ever proven. Stale entries go only via the permissionless, stale-gated clears, so an account farming Merkl across many root epochs drifts toward the cap unless the keeper also collects garbage.
At the cap, a submit carrying a new token reverts — and because a submit is atomic over its whole list, that reverts the refresh of the account's other tokens too. A keeper at the cap stops updating everything, not just the new token.
Per-token isolation does not make an under-budgeted read survivable: with a full cache and an expensive token valued last, the read can revert outright. Size the budget off the product.
Failure behaviour
Writes fail closed with named errors — InvalidProof (proof does not verify against the live root), AmountRegression (a cumulative below the cached value for the same root), CampaignSumMismatch (shares do not sum to the proven total), TooManyCampaigns (breakdown over the cap). A rejected submit leaves the previous cached value intact rather than a partial one.
Reads never revert on a bad cache entry: a stale epoch reads 0, and a token whose asset is unregistered on the NAV contributes nothing.
One silent failure mode worth monitoring. The live netting reads claimed off the Distributor unguarded and ABI-decodes the result, so if the Distributor — an upgradeable proxy under Angle governance — ever returns something undecodable, the read reverts. On the NAV path that means this adapter's whole leg set drops out for the account: an under-report, the safe direction, but with no error surfaced. The family diagnostic applies.
Last updated