For the complete documentation index, see llms.txt. This page is also available as Markdown.

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 distinguish

  • positionId: abi.encode(DISTRIBUTOR)identical for every leg of every token

  • positionInstanceId: 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.

Leg
PositionKind
isDebt
Amount

Claimable Rewards

Rewards

false

Proven cumulative − claimed, in the reward token


Reading the legs — sum them, never de-duplicate

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:

  • isAttestationLive can flap for the same element — false during a rotation, true again if the root rolls back. A monitor that pages on a single false will page on dispute windows.

  • A clear performed while the root is rolled back is not undone when the root returns. clearAttestation is 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.

AmountRegression should never fire. For a well-formed Merkl tree it is unreachable — the amount is committed inside the leaf, so exactly one value verifies per (user, token) under one root. It is a tripwire, not a protection: alert on any occurrence rather than retrying.


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.

Last updated