Skip to content

Commit 676e533

Browse files
committed
Cap Merkl MAX_APR incentive rewards at aprCap (v158: +migration & backfill)
1 parent 773458c commit 676e533

6 files changed

Lines changed: 86 additions & 11 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
module.exports = class AddMerklDistributionFields1782950000000 {
2+
name = 'AddMerklDistributionFields1782950000000'
3+
4+
// Adds the Merkl distribution-strategy fields to the live merkl_campaign table (the running
5+
// v158 DB predates them), then backfills the one existing ARM campaign. Its row was captured
6+
// before these fields existed, so without the backfill it would keep using the uncapped max
7+
// budget instead of the MAX_APR (2%) cap. New campaigns capture these at resolve time.
8+
async up(db) {
9+
await db.query(`ALTER TABLE "merkl_campaign" ADD "distribution_method" text`)
10+
await db.query(`ALTER TABLE "merkl_campaign" ADD "apr_cap" numeric`)
11+
await db.query(
12+
`UPDATE "merkl_campaign" SET "distribution_method" = 'MAX_APR', "apr_cap" = 0.02 ` +
13+
`WHERE "campaign_id" = '0x459f4891c0a39a940a938e81122017aa8d7509abf73cddef3b5fb7c14bb3124c'`,
14+
)
15+
}
16+
17+
async down(db) {
18+
await db.query(`ALTER TABLE "merkl_campaign" DROP COLUMN "apr_cap"`)
19+
await db.query(`ALTER TABLE "merkl_campaign" DROP COLUMN "distribution_method"`)
20+
}
21+
}

schema.graphql

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,12 @@ type MerklCampaign @entity {
109109
endTimestamp: DateTime!
110110
# Seconds; mutable via overrideCampaign.
111111
duration: Int!
112+
# Merkl distribution strategy (params.distributionMethodParameters.distributionMethod), e.g.
113+
# "MAX_APR". Determines actual emission vs the max (amount/duration). null if unknown.
114+
distributionMethod: String
115+
# APR cap for MAX_APR campaigns (params.distributionSettings.apr, e.g. 0.02 = 2%); null when
116+
# uncapped. Actual daily rewards = min(amount/duration, aprCap * TVL / 365).
117+
aprCap: Float
112118
# Raw 32-byte campaignData (off-chain payload pointer/hash), for reference.
113119
campaignData: String!
114120
# Resolved via Merkl API (params.targetToken) at capture time: the contract whose

src/mainnet/processors/merkl.graphql

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ type MerklCampaign @entity {
2020
endTimestamp: DateTime!
2121
# Seconds; mutable via overrideCampaign.
2222
duration: Int!
23+
# Merkl distribution strategy (params.distributionMethodParameters.distributionMethod), e.g.
24+
# "MAX_APR". Determines actual emission vs the max (amount/duration). null if unknown.
25+
distributionMethod: String
26+
# APR cap for MAX_APR campaigns (params.distributionSettings.apr, e.g. 0.02 = 2%); null when
27+
# uncapped. Actual daily rewards = min(amount/duration, aprCap * TVL / 365).
28+
aprCap: Float
2329
# Raw 32-byte campaignData (off-chain payload pointer/hash), for reference.
2430
campaignData: String!
2531
# Resolved via Merkl API (params.targetToken) at capture time: the contract whose

src/mainnet/processors/merkl.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,24 @@ type CampaignStruct = ReturnType<typeof abi.events.NewCampaign.decode>['campaign
2929
* so Subsquid retries it later — no partial/null data is ever written, and the processor
3030
* pauses rather than crashing.
3131
*/
32-
type ResolvedCampaign = { targetToken: string | null; rewardTokenDecimals: number }
32+
type ResolvedCampaign = {
33+
targetToken: string | null
34+
rewardTokenDecimals: number
35+
distributionMethod: string | null
36+
aprCap: number | null
37+
}
38+
39+
type MerklCampaignApi = {
40+
campaignId?: string
41+
params?: {
42+
targetToken?: string
43+
decimalsRewardToken?: number
44+
distributionMethodParameters?: {
45+
distributionMethod?: string
46+
distributionSettings?: { apr?: string | number }
47+
}
48+
}
49+
}
3350

3451
const resolveCampaign = async (ctx: Context, chainId: number, campaignId: string): Promise<ResolvedCampaign> => {
3552
// test=true includes test campaigns in the response (additive — live campaigns still returned).
@@ -41,12 +58,19 @@ const resolveCampaign = async (ctx: Context, chainId: number, campaignId: string
4158
try {
4259
const res = await fetch(url)
4360
if (!res.ok) throw new Error(`status ${res.status}`)
44-
const data = (await res.json()) as { campaignId?: string; params?: { targetToken?: string; decimalsRewardToken?: number } }[]
61+
const data = (await res.json()) as MerklCampaignApi[]
4562
const match = data.find((c) => c?.campaignId?.toLowerCase() === campaignId.toLowerCase())
4663
if (!match) throw new Error('campaign not yet indexed by Merkl')
64+
const dist = match.params?.distributionMethodParameters
65+
// MAX_APR campaigns cap the reward rate; distributionSettings.apr is the cap (e.g. "0.02").
66+
const aprCap = dist?.distributionMethod === 'MAX_APR' && dist.distributionSettings?.apr != null
67+
? Number(dist.distributionSettings.apr)
68+
: null
4769
return {
4870
targetToken: match.params?.targetToken?.toLowerCase() ?? null,
4971
rewardTokenDecimals: match.params?.decimalsRewardToken ?? 18,
72+
distributionMethod: dist?.distributionMethod ?? null,
73+
aprCap: Number.isFinite(aprCap) ? aprCap : null,
5074
}
5175
} catch (err) {
5276
lastError = err
@@ -77,7 +101,11 @@ const upsertFromStruct = async (
77101
if (!creatorSet.has(campaign.creator.toLowerCase())) return
78102

79103
const id = `${ctx.chain.id}:${campaign.campaignId}`
80-
const { targetToken, rewardTokenDecimals } = await resolveCampaign(ctx, ctx.chain.id, campaign.campaignId)
104+
const { targetToken, rewardTokenDecimals, distributionMethod, aprCap } = await resolveCampaign(
105+
ctx,
106+
ctx.chain.id,
107+
campaign.campaignId,
108+
)
81109
const timestamp = new Date(block.header.timestamp)
82110
const existing = campaigns.get(id) ?? (await ctx.store.get(MerklCampaign, id))
83111

@@ -97,6 +125,8 @@ const upsertFromStruct = async (
97125

98126
// Resolved target (Merkl API), and the ARM it rewards when the target is one of ours.
99127
entity.rewardTokenDecimals = rewardTokenDecimals
128+
entity.distributionMethod = distributionMethod
129+
entity.aprCap = aprCap
100130
entity.targetToken = targetToken
101131
entity.armAddress = targetToken && armSet.has(targetToken) ? targetToken : null
102132
// Mutable fields (overrideCampaign can change start/duration/campaignData).

src/model/generated/merklCampaign.model.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {Entity as Entity_, Column as Column_, PrimaryColumn as PrimaryColumn_, IntColumn as IntColumn_, Index as Index_, StringColumn as StringColumn_, BigIntColumn as BigIntColumn_, DateTimeColumn as DateTimeColumn_} from "@subsquid/typeorm-store"
1+
import {Entity as Entity_, Column as Column_, PrimaryColumn as PrimaryColumn_, IntColumn as IntColumn_, Index as Index_, StringColumn as StringColumn_, BigIntColumn as BigIntColumn_, DateTimeColumn as DateTimeColumn_, FloatColumn as FloatColumn_} from "@subsquid/typeorm-store"
22

33
@Entity_()
44
export class MerklCampaign {
@@ -44,6 +44,12 @@ export class MerklCampaign {
4444
@IntColumn_({nullable: false})
4545
duration!: number
4646

47+
@StringColumn_({nullable: true})
48+
distributionMethod!: string | undefined | null
49+
50+
@FloatColumn_({nullable: true})
51+
aprCap!: number | undefined | null
52+
4753
@StringColumn_({nullable: false})
4854
campaignData!: string
4955

src/templates/origin-arm/arm-incentive-apy.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ const TOKEN0_DECIMALS = 18
1919
* - incentiveYield: that value expressed in token0 units (parallel to ArmDailyStat.yield)
2020
* - incentiveApr: dailyRewardUSD / TVL_USD * 365.25
2121
* - incentiveApy: the same, daily-compounded
22+
* MAX_APR campaigns (aprCap set) don't emit their full budget — Merkl caps the daily payout at
23+
* aprCap * TVL / 365 — so we use min(maxDaily, cappedDaily), matching what holders actually earn.
2224
* Reward tokens are valued via the exchange-rate infra; unpriceable ones are skipped rather than
2325
* failing the batch. Returns zeros when no campaigns are active.
2426
*/
@@ -50,6 +52,10 @@ export const calculateArmIncentiveApy = async (
5052
})
5153
if (campaigns.length === 0) return zero
5254

55+
const token0PriceUSD = +formatUnits(rateUSD?.rate ?? 0n, rateUSD?.decimals ?? 18)
56+
if (token0PriceUSD <= 0) return zero
57+
const tvlUSD = +formatUnits(totalAssets, TOKEN0_DECIMALS) * token0PriceUSD
58+
5359
let dailyRewardsUSD = 0
5460
for (const campaign of campaigns) {
5561
const durationDays = campaign.duration / 86_400
@@ -64,22 +70,22 @@ export const calculateArmIncentiveApy = async (
6470
continue
6571
}
6672
if (priceUSD <= 0) continue
67-
const dailyTokens = +formatUnits(campaign.amount, campaign.rewardTokenDecimals) / durationDays
73+
// Max daily emission: the full budget spread evenly over the campaign duration.
74+
const maxDailyUSD = (+formatUnits(campaign.amount, campaign.rewardTokenDecimals) / durationDays) * priceUSD
75+
// MAX_APR campaigns cap the rate: Merkl distributes only min(maxDaily, aprCap * TVL / 365), so a
76+
// small pool earns far less than the max budget. Uncapped campaigns emit the full daily budget.
77+
const dailyUSD =
78+
campaign.aprCap != null && tvlUSD > 0 ? Math.min(maxDailyUSD, (campaign.aprCap * tvlUSD) / 365) : maxDailyUSD
6879
// Prorate the first/last day by how much of it the campaign is active.
6980
const overlapMs =
7081
Math.min(endOfDay.getTime(), campaign.endTimestamp.getTime()) -
7182
Math.max(startOfDay.getTime(), campaign.startTimestamp.getTime())
7283
const activeFraction = Math.max(0, Math.min(1, overlapMs / DAY_MS))
73-
dailyRewardsUSD += dailyTokens * priceUSD * activeFraction
84+
dailyRewardsUSD += dailyUSD * activeFraction
7485
}
7586

76-
const token0PriceUSD = +formatUnits(rateUSD?.rate ?? 0n, rateUSD?.decimals ?? 18)
77-
if (token0PriceUSD <= 0) return zero
78-
7987
// Express the day's reward value in token0 units, to parallel ArmDailyStat.yield.
8088
const incentiveYield = parseUnits((dailyRewardsUSD / token0PriceUSD).toFixed(TOKEN0_DECIMALS), TOKEN0_DECIMALS)
81-
82-
const tvlUSD = +formatUnits(totalAssets, TOKEN0_DECIMALS) * token0PriceUSD
8389
if (tvlUSD <= 0) return { ...zero, incentiveYield }
8490

8591
const dailyRate = dailyRewardsUSD / tvlUSD

0 commit comments

Comments
 (0)