|
| 1 | +/** |
| 2 | + * Incentive Service (WORK IN PROGRESS) |
| 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 |
| 11 | + */ |
| 12 | + |
| 13 | +import { Data, Effect, Schema, pipe } from "effect" |
| 14 | +import { HttpClient, HttpClientResponse } from "@effect/platform" |
| 15 | + |
| 16 | +const REST_BASE_URL = import.meta.env.DEV ? "/api/union" : "https://rest.union.build" |
| 17 | + |
| 18 | +export class IncentiveError extends Data.TaggedError("IncentiveError")<{ |
| 19 | + message: string |
| 20 | + cause?: unknown |
| 21 | +}> {} |
| 22 | + |
| 23 | +const InflationResponse = Schema.Struct({ |
| 24 | + inflation: Schema.String, |
| 25 | +}) |
| 26 | + |
| 27 | +const StakingPoolResponse = Schema.Struct({ |
| 28 | + pool: Schema.Struct({ |
| 29 | + not_bonded_tokens: Schema.String, |
| 30 | + bonded_tokens: Schema.String, |
| 31 | + }), |
| 32 | +}) |
| 33 | + |
| 34 | +const DistributionParamsResponse = Schema.Struct({ |
| 35 | + params: Schema.Struct({ |
| 36 | + community_tax: Schema.String, |
| 37 | + base_proposer_reward: Schema.String, |
| 38 | + bonus_proposer_reward: Schema.String, |
| 39 | + withdraw_addr_enabled: Schema.Boolean, |
| 40 | + }), |
| 41 | +}) |
| 42 | + |
| 43 | +const CirculatingSupplyResponse = Schema.Struct({ |
| 44 | + amount: Schema.Struct({ |
| 45 | + denom: Schema.String, |
| 46 | + amount: Schema.String, |
| 47 | + }), |
| 48 | +}) |
| 49 | + |
| 50 | +// Schema for the incentive calculation result |
| 51 | +export const IncentiveResult = Schema.Struct({ |
| 52 | + rates: Schema.Struct({ |
| 53 | + yearly: Schema.Number, |
| 54 | + }), |
| 55 | + incentiveNominal: Schema.Number, |
| 56 | + incentiveAfterTax: Schema.Number, |
| 57 | + inflation: Schema.Number, |
| 58 | + totalSupply: Schema.Number, |
| 59 | + bondedTokens: Schema.Number, |
| 60 | + communityTax: Schema.Number, |
| 61 | + bondedRatio: Schema.Number, |
| 62 | +}) |
| 63 | + |
| 64 | +export type IncentiveResult = Schema.Schema.Type<typeof IncentiveResult> |
| 65 | + |
| 66 | +const getInflation = pipe( |
| 67 | + HttpClient.get(`${REST_BASE_URL}/cosmos/mint/v1beta1/inflation`), |
| 68 | + Effect.flatMap(HttpClientResponse.schemaBodyJson(InflationResponse)), |
| 69 | + Effect.mapError((cause) => new IncentiveError({ |
| 70 | + message: "Failed to fetch inflation rate", |
| 71 | + cause, |
| 72 | + })), |
| 73 | +) |
| 74 | + |
| 75 | +const getStakingPool = pipe( |
| 76 | + HttpClient.get(`${REST_BASE_URL}/cosmos/staking/v1beta1/pool`), |
| 77 | + Effect.flatMap(HttpClientResponse.schemaBodyJson(StakingPoolResponse)), |
| 78 | + Effect.mapError((cause) => new IncentiveError({ |
| 79 | + message: "Failed to fetch staking pool", |
| 80 | + cause, |
| 81 | + })), |
| 82 | +) |
| 83 | + |
| 84 | +const getDistributionParams = pipe( |
| 85 | + HttpClient.get(`${REST_BASE_URL}/cosmos/distribution/v1beta1/params`), |
| 86 | + Effect.flatMap(HttpClientResponse.schemaBodyJson(DistributionParamsResponse)), |
| 87 | + Effect.mapError((cause) => new IncentiveError({ |
| 88 | + message: "Failed to fetch distribution params", |
| 89 | + cause, |
| 90 | + })), |
| 91 | +) |
| 92 | + |
| 93 | +const getCirculatingSupply = pipe( |
| 94 | + HttpClient.get(`${REST_BASE_URL}/cosmos/bank/v1beta1/supply/by_denom?denom=au`), |
| 95 | + Effect.flatMap(HttpClientResponse.schemaBodyJson(CirculatingSupplyResponse)), |
| 96 | + Effect.mapError((cause) => new IncentiveError({ |
| 97 | + message: "Failed to fetch circulating supply", |
| 98 | + cause, |
| 99 | + })), |
| 100 | +) |
| 101 | + |
| 102 | +export const calculateIncentive: Effect.Effect<IncentiveResult, IncentiveError, HttpClient.HttpClient> = Effect.gen(function*() { |
| 103 | + const [inflationData, stakingPoolData, distributionData, circulatingSupplyData] = yield* Effect.all([ |
| 104 | + getInflation, |
| 105 | + getStakingPool, |
| 106 | + getDistributionParams, |
| 107 | + getCirculatingSupply, |
| 108 | + ], { concurrency: "unbounded" }) |
| 109 | + |
| 110 | + const inflation = parseFloat(inflationData.inflation) |
| 111 | + const bondedTokensRaw = parseFloat(stakingPoolData.pool.bonded_tokens) |
| 112 | + const communityTax = parseFloat(distributionData.params.community_tax) |
| 113 | + const circulatingSupplyRaw = parseFloat(circulatingSupplyData.amount.amount) |
| 114 | + |
| 115 | + const bondedTokens = bondedTokensRaw / 1_000_000_000_000_000_000 |
| 116 | + const totalSupply = circulatingSupplyRaw / 1_000_000_000_000_000_000 |
| 117 | + |
| 118 | + if (isNaN(inflation) || isNaN(bondedTokens) || isNaN(totalSupply) || isNaN(communityTax)) { |
| 119 | + return yield* Effect.fail(new IncentiveError({ |
| 120 | + message: "Invalid numeric values in API responses", |
| 121 | + })) |
| 122 | + } |
| 123 | + |
| 124 | + if (totalSupply === 0) { |
| 125 | + return yield* Effect.fail(new IncentiveError({ |
| 126 | + message: "Invalid total supply", |
| 127 | + })) |
| 128 | + } |
| 129 | + |
| 130 | + if (bondedTokens === 0) { |
| 131 | + return yield* Effect.fail(new IncentiveError({ |
| 132 | + message: "No bonded tokens found", |
| 133 | + })) |
| 134 | + } |
| 135 | + |
| 136 | + // Step 1: Calculate nominal incentive rate |
| 137 | + const incentiveNominal = (inflation * totalSupply) / bondedTokens |
| 138 | + |
| 139 | + // Step 2: Apply community tax |
| 140 | + const incentiveAfterTax = incentiveNominal * (1 - communityTax) |
| 141 | + |
| 142 | + return { |
| 143 | + rates: { |
| 144 | + yearly: incentiveAfterTax, |
| 145 | + }, |
| 146 | + |
| 147 | + incentiveNominal, |
| 148 | + incentiveAfterTax, |
| 149 | + inflation, |
| 150 | + totalSupply, |
| 151 | + bondedTokens, |
| 152 | + communityTax, |
| 153 | + bondedRatio: bondedTokens / totalSupply, |
| 154 | + } |
| 155 | +}) |
| 156 | + |
| 157 | +// Helper to format incentive as percentage |
| 158 | +export const formatIncentive = (incentive: number): string => { |
| 159 | + return `${(incentive * 100).toFixed(2)}%` |
| 160 | +} |
0 commit comments