1
1
/**
2
- * Incentive Service (WORK IN PROGRESS)
2
+ * Incentive Service
3
3
*
4
- * Calculates staking incentives using:
5
- * - Total supply from cosmos bank module
6
- * - Inflation rate from cosmos mint module
7
- * - Bonded token supply from staking pool
8
- * - Community tax from distribution params
9
- *
10
- * Formula: Incentive = ((1 + [(inflation × total_supply ÷ bonded) × (1 − tax)] ÷ 365) ^ 365) − 1
4
+ * Calculates liquid staking incentives including:
5
+ * - Base staking rewards (inflation × total_supply ÷ bonded_tokens)
6
+ * - Community tax deduction
7
+ * - Validator commission deduction (weighted average from delegated validators)
11
8
*/
12
9
13
10
import { HttpClient , HttpClientResponse } from "@effect/platform"
14
- import { BigDecimal , Data , Effect , pipe , Schema } from "effect"
11
+ import { EU_STAKING_HUB } from "@unionlabs/sdk/Constants"
12
+ import { Array , BigDecimal , Data , Effect , Option as O , pipe , Schema } from "effect"
15
13
16
14
const REST_BASE_URL = "https://rest.union.build"
17
15
@@ -47,13 +45,61 @@ const CirculatingSupplyResponse = Schema.Struct({
47
45
} ) ,
48
46
} )
49
47
50
- // Schema for the incentive calculation result
48
+ const ValidatorsResponse = Schema . Struct ( {
49
+ validators : Schema . Array ( Schema . Struct ( {
50
+ operator_address : Schema . String ,
51
+ tokens : Schema . BigDecimal ,
52
+ commission : Schema . Struct ( {
53
+ commission_rates : Schema . Struct ( {
54
+ rate : Schema . BigDecimal ,
55
+ } ) ,
56
+ } ) ,
57
+ status : Schema . String ,
58
+ jailed : Schema . Boolean ,
59
+ } ) ) ,
60
+ } )
61
+
62
+ const DelegatorDelegationsResponse = Schema . Struct ( {
63
+ delegation_responses : Schema . Array ( Schema . Struct ( {
64
+ delegation : Schema . Struct ( {
65
+ delegator_address : Schema . String ,
66
+ validator_address : Schema . String ,
67
+ shares : Schema . BigDecimal ,
68
+ } ) ,
69
+ balance : Schema . Struct ( {
70
+ denom : Schema . String ,
71
+ amount : Schema . BigDecimal ,
72
+ } ) ,
73
+ } ) ) ,
74
+ } )
75
+
76
+ const LstConfigResponse = Schema . Struct ( {
77
+ data : Schema . Struct ( {
78
+ staker_address : Schema . String ,
79
+ native_token_denom : Schema . String ,
80
+ minimum_liquid_stake_amount : Schema . String ,
81
+ protocol_fee_config : Schema . Struct ( {
82
+ fee_rate : Schema . String ,
83
+ fee_recipient : Schema . String ,
84
+ } ) ,
85
+ monitors : Schema . Array ( Schema . String ) ,
86
+ lst_address : Schema . String ,
87
+ batch_period_seconds : Schema . Number ,
88
+ unbonding_period_seconds : Schema . Number ,
89
+ stopped : Schema . Boolean ,
90
+ } ) ,
91
+ } )
92
+
51
93
export const IncentiveResult = Schema . Struct ( {
52
94
rates : Schema . Struct ( {
53
95
yearly : Schema . BigDecimalFromSelf ,
54
96
} ) ,
55
97
incentiveNominal : Schema . BigDecimalFromSelf ,
56
98
incentiveAfterTax : Schema . BigDecimalFromSelf ,
99
+ incentiveAfterCommission : Schema . BigDecimalFromSelf ,
100
+ communityTaxAmount : Schema . BigDecimalFromSelf ,
101
+ validatorCommissionAmount : Schema . BigDecimalFromSelf ,
102
+ weightedAverageCommission : Schema . BigDecimalFromSelf ,
57
103
inflation : Schema . BigDecimalFromSelf ,
58
104
totalSupply : Schema . BigDecimalFromSelf ,
59
105
bondedTokens : Schema . BigDecimalFromSelf ,
@@ -131,17 +177,85 @@ const getCirculatingSupply = pipe(
131
177
) ,
132
178
)
133
179
180
+ const getValidators = pipe (
181
+ HttpClient . HttpClient ,
182
+ Effect . map ( HttpClient . withTracerDisabledWhen ( ( ) => true ) ) ,
183
+ Effect . andThen ( ( client ) =>
184
+ pipe (
185
+ client . get ( `${ REST_BASE_URL } /cosmos/staking/v1beta1/validators?status=BOND_STATUS_BONDED` ) ,
186
+ Effect . flatMap ( HttpClientResponse . schemaBodyJson ( ValidatorsResponse ) ) ,
187
+ Effect . mapError ( ( cause ) =>
188
+ new IncentiveError ( {
189
+ message : "Failed to fetch validators" ,
190
+ cause,
191
+ } )
192
+ ) ,
193
+ )
194
+ ) ,
195
+ )
196
+
197
+ const getLstConfig = pipe (
198
+ HttpClient . HttpClient ,
199
+ Effect . map ( HttpClient . withTracerDisabledWhen ( ( ) => true ) ) ,
200
+ Effect . andThen ( ( client ) => {
201
+ const queryMsg = btoa ( JSON . stringify ( { config : { } } ) )
202
+ return pipe (
203
+ client . get (
204
+ `${ REST_BASE_URL } /cosmwasm/wasm/v1/contract/${ EU_STAKING_HUB . address } /smart/${ queryMsg } ` ,
205
+ ) ,
206
+ Effect . flatMap ( HttpClientResponse . schemaBodyJson ( LstConfigResponse ) ) ,
207
+ Effect . mapError ( ( cause ) =>
208
+ new IncentiveError ( {
209
+ message : "Failed to fetch LST contract config" ,
210
+ cause,
211
+ } )
212
+ ) ,
213
+ )
214
+ } ) ,
215
+ )
216
+
217
+ const getDelegatorDelegations = ( delegatorAddress : string ) =>
218
+ pipe (
219
+ HttpClient . HttpClient ,
220
+ Effect . map ( HttpClient . withTracerDisabledWhen ( ( ) => true ) ) ,
221
+ Effect . andThen ( ( client ) =>
222
+ pipe (
223
+ client . get ( `${ REST_BASE_URL } /cosmos/staking/v1beta1/delegations/${ delegatorAddress } ` ) ,
224
+ Effect . flatMap ( HttpClientResponse . schemaBodyJson ( DelegatorDelegationsResponse ) ) ,
225
+ Effect . mapError ( ( cause ) =>
226
+ new IncentiveError ( {
227
+ message : "Failed to fetch delegator delegations" ,
228
+ cause,
229
+ } )
230
+ ) ,
231
+ )
232
+ ) ,
233
+ )
234
+
134
235
export const calculateIncentive : Effect . Effect <
135
236
IncentiveResult ,
136
237
IncentiveError ,
137
238
HttpClient . HttpClient
138
239
> = Effect . gen ( function * ( ) {
139
- const [ inflationData , stakingPoolData , distributionData , circulatingSupplyData ] = yield * Effect
240
+ // First get the LST config to find the staker address
241
+ const lstConfig = yield * getLstConfig
242
+ const stakerAddress = lstConfig . data . staker_address
243
+
244
+ const [
245
+ inflationData ,
246
+ stakingPoolData ,
247
+ distributionData ,
248
+ circulatingSupplyData ,
249
+ validatorsData ,
250
+ delegationsData ,
251
+ ] = yield * Effect
140
252
. all ( [
141
253
getInflation ,
142
254
getStakingPool ,
143
255
getDistributionParams ,
144
256
getCirculatingSupply ,
257
+ getValidators ,
258
+ getDelegatorDelegations ( stakerAddress ) ,
145
259
] , { concurrency : "unbounded" } )
146
260
147
261
const inflation = inflationData . inflation
@@ -181,7 +295,6 @@ export const calculateIncentive: Effect.Effect<
181
295
)
182
296
}
183
297
184
- // Step 1: Calculate nominal incentive rate
185
298
const incentiveNominal = yield * pipe (
186
299
BigDecimal . multiply ( inflation , totalSupply ) ,
187
300
BigDecimal . divide ( bondedTokens ) ,
@@ -192,11 +305,55 @@ export const calculateIncentive: Effect.Effect<
192
305
) ,
193
306
)
194
307
195
- // Step 2: Apply community tax
196
- const incentiveAfterTax = BigDecimal . multiply (
197
- incentiveNominal ,
198
- BigDecimal . subtract ( BigDecimal . fromBigInt ( 1n ) , communityTax ) ,
308
+ const communityTaxAmount = BigDecimal . multiply ( incentiveNominal , communityTax )
309
+ const incentiveAfterTax = BigDecimal . subtract ( incentiveNominal , communityTaxAmount )
310
+
311
+ // Calculate weighted average validator commission
312
+ const validDelegations = pipe (
313
+ delegationsData . delegation_responses ,
314
+ Array . filterMap ( delegation => {
315
+ const validator = pipe (
316
+ validatorsData . validators ,
317
+ Array . findFirst ( v => v . operator_address === delegation . delegation . validator_address ) ,
318
+ )
319
+
320
+ return pipe (
321
+ validator ,
322
+ O . filter ( v => ! v . jailed && v . status === "BOND_STATUS_BONDED" ) ,
323
+ O . map ( v => ( {
324
+ amount : delegation . balance . amount ,
325
+ commission : v . commission . commission_rates . rate ,
326
+ } ) ) ,
327
+ )
328
+ } ) ,
329
+ )
330
+
331
+ const { totalAmount, weightedSum } = pipe (
332
+ validDelegations ,
333
+ Array . reduce (
334
+ { totalAmount : BigDecimal . fromBigInt ( 0n ) , weightedSum : BigDecimal . fromBigInt ( 0n ) } ,
335
+ ( acc , { amount, commission } ) => ( {
336
+ totalAmount : BigDecimal . sum ( acc . totalAmount , amount ) ,
337
+ weightedSum : BigDecimal . sum ( acc . weightedSum , BigDecimal . multiply ( amount , commission ) ) ,
338
+ } ) ,
339
+ ) ,
340
+ )
341
+
342
+ const weightedAverageCommission = BigDecimal . isZero ( totalAmount )
343
+ ? BigDecimal . fromBigInt ( 0n )
344
+ : yield * BigDecimal . divide ( weightedSum , totalAmount ) . pipe (
345
+ Effect . mapError ( ( ) =>
346
+ new IncentiveError ( {
347
+ message : "Could not calculate weighted average commission" ,
348
+ } )
349
+ ) ,
350
+ )
351
+
352
+ const validatorCommissionAmount = BigDecimal . multiply (
353
+ incentiveAfterTax ,
354
+ weightedAverageCommission ,
199
355
)
356
+ const incentiveAfterCommission = BigDecimal . subtract ( incentiveAfterTax , validatorCommissionAmount )
200
357
201
358
const bondedRatio = yield * BigDecimal . divide ( bondedTokens , totalSupply ) . pipe (
202
359
Effect . mapError ( ( ) =>
@@ -208,10 +365,14 @@ export const calculateIncentive: Effect.Effect<
208
365
209
366
return {
210
367
rates : {
211
- yearly : incentiveAfterTax ,
368
+ yearly : incentiveAfterCommission ,
212
369
} ,
213
370
incentiveNominal,
214
371
incentiveAfterTax,
372
+ incentiveAfterCommission,
373
+ communityTaxAmount,
374
+ validatorCommissionAmount,
375
+ weightedAverageCommission,
215
376
inflation,
216
377
totalSupply,
217
378
bondedTokens,
0 commit comments