Skip to content

Commit 4d09549

Browse files
committed
feat(app): incentive wip
1 parent 7270c07 commit 4d09549

File tree

3 files changed

+371
-77
lines changed

3 files changed

+371
-77
lines changed
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
<script lang="ts">
2+
//WORK IN PROGRESS
3+
import { formatIncentive } from "$lib/services/incentive"
4+
import Card from "$lib/components/ui/Card.svelte"
5+
import Tabs from "$lib/components/ui/Tabs.svelte"
6+
import { matchRuntimeResult } from "$lib/utils/snippets.svelte"
7+
import type { Option, Exit } from "effect"
8+
9+
interface Props {
10+
incentives: Option.Option<Exit.Exit<any, any>>
11+
}
12+
13+
let { incentives }: Props = $props()
14+
15+
let selectedTab: "incentive" | "rewards" = $state("incentive")
16+
17+
$effect(() => {
18+
console.log('IncentiveCard received incentives:', incentives)
19+
})
20+
21+
function formatPercentage(value: number): string {
22+
return formatIncentive(value)
23+
}
24+
25+
function formatLargeNumber(value: number): string {
26+
if (value >= 1_000_000_000) {
27+
return `${(value / 1_000_000_000).toFixed(1)}B`
28+
} else if (value >= 1_000_000) {
29+
return `${(value / 1_000_000).toFixed(1)}M`
30+
} else if (value >= 1_000) {
31+
return `${(value / 1_000).toFixed(1)}K`
32+
}
33+
return value.toLocaleString()
34+
}
35+
</script>
36+
37+
{#snippet renderIncentiveData(data: any)}
38+
<div class="flex flex-col h-full">
39+
<!-- Header with Tabs -->
40+
<div class="p-2 border-b border-zinc-800">
41+
<Tabs
42+
items={[
43+
{ id: "incentive", label: "Incentive" }
44+
]}
45+
activeId={selectedTab}
46+
onTabChange={(id) => selectedTab = id as "incentive" | "rewards"}
47+
/>
48+
</div>
49+
50+
<!-- Content -->
51+
<div class="p-2 flex flex-col flex-1">
52+
{#if selectedTab === "incentive"}
53+
<!-- Incentive Tab Content (APY equivalent) -->
54+
<div class="flex justify-center mb-2">
55+
<div class="flex flex-col items-center justify-center p-4 bg-zinc-800/30 rounded-lg w-full">
56+
<div class="text-4xl font-bold text-accent mb-2">
57+
{formatPercentage(data.rates.yearly)}
58+
</div>
59+
<div class="text-sm text-zinc-400">Annual Compounded Rate</div>
60+
</div>
61+
</div>
62+
63+
<!-- 2x2 Grid of All Metrics -->
64+
<div class="grid grid-cols-2 gap-2 flex-1">
65+
<div class="flex flex-col items-center justify-center p-4 bg-zinc-800/30 rounded-lg">
66+
<div class="text-lg font-mono text-white mb-1">
67+
{formatLargeNumber(data.totalSupply)}
68+
</div>
69+
<div class="text-xs text-zinc-500">Total Supply</div>
70+
</div>
71+
<div class="flex flex-col items-center justify-center p-4 bg-zinc-800/30 rounded-lg">
72+
<div class="text-lg font-mono text-white mb-1">
73+
{formatLargeNumber(data.bondedTokens)}
74+
</div>
75+
<div class="text-xs text-zinc-500">Bonded Tokens</div>
76+
</div>
77+
<div class="flex flex-col items-center justify-center p-4 bg-zinc-800/30 rounded-lg">
78+
<div class="text-lg font-semibold text-white mb-1">
79+
{(data.bondedRatio * 100).toFixed(1)}%
80+
</div>
81+
<div class="text-xs text-zinc-500">Bonded Ratio</div>
82+
</div>
83+
<div class="flex flex-col items-center justify-center p-4 bg-zinc-800/30 rounded-lg">
84+
<div class="text-lg font-semibold text-white mb-1">
85+
{(data.inflation * 100).toFixed(1)}%
86+
</div>
87+
<div class="text-xs text-zinc-500">Inflation Rate</div>
88+
</div>
89+
</div>
90+
{/if}
91+
</div>
92+
</div>
93+
{/snippet}
94+
95+
{#snippet renderLoading()}
96+
<div class="flex flex-col h-full">
97+
<!-- Tabs Skeleton -->
98+
<div class="pt-2 px-2 pb-2 border-b border-zinc-800">
99+
<div class="flex gap-1">
100+
<div class="h-8 w-20 bg-zinc-700/50 rounded animate-pulse"></div>
101+
<div class="h-8 w-20 bg-zinc-700/50 rounded animate-pulse"></div>
102+
</div>
103+
</div>
104+
105+
<!-- Content Skeleton -->
106+
<div class="px-3 pb-3 flex flex-col flex-1">
107+
<!-- Main Display Skeleton -->
108+
<div class="flex justify-center mb-4 mt-3">
109+
<div class="flex flex-col items-center justify-center p-4 bg-zinc-800/30 rounded-lg">
110+
<div class="h-12 w-32 bg-zinc-700/50 rounded mb-2 animate-pulse"></div>
111+
<div class="h-4 w-36 bg-zinc-700/50 rounded animate-pulse"></div>
112+
</div>
113+
</div>
114+
115+
<!-- 2x2 Grid Skeleton -->
116+
<div class="grid grid-cols-2 gap-4 flex-1">
117+
{#each Array(4) as _}
118+
<div class="flex flex-col items-center justify-center p-4 bg-zinc-800/30 rounded-lg">
119+
<div class="h-5 w-16 bg-zinc-700/50 rounded mb-1 animate-pulse"></div>
120+
<div class="h-3 w-20 bg-zinc-700/50 rounded animate-pulse"></div>
121+
</div>
122+
{/each}
123+
</div>
124+
</div>
125+
</div>
126+
{/snippet}
127+
128+
{#snippet renderError(error: any)}
129+
<div>
130+
<div class="text-center">
131+
<div class="text-red-400 text-sm mb-2">Failed to load incentive data</div>
132+
<div class="text-xs text-zinc-500">
133+
{error?.message || 'Unknown error occurred'}
134+
</div>
135+
</div>
136+
</div>
137+
{/snippet}
138+
139+
<Card class="p-0">
140+
{@render matchRuntimeResult(incentives, {
141+
onSuccess: renderIncentiveData,
142+
onFailure: renderError,
143+
onNone: renderLoading,
144+
})}
145+
</Card>

app2/src/lib/services/incentive.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
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

Comments
 (0)