diff --git a/kraiken-lib/package.json b/kraiken-lib/package.json index 0998c67..b1314ad 100644 --- a/kraiken-lib/package.json +++ b/kraiken-lib/package.json @@ -45,6 +45,11 @@ "types": "./dist/version.d.ts", "require": "./dist/version.js", "import": "./dist/version.js" + }, + "./position": { + "types": "./dist/position.d.ts", + "require": "./dist/position.js", + "import": "./dist/position.js" } }, "files": [ diff --git a/kraiken-lib/src/position.ts b/kraiken-lib/src/position.ts new file mode 100644 index 0000000..d2c8a4a --- /dev/null +++ b/kraiken-lib/src/position.ts @@ -0,0 +1,78 @@ +/** + * Position profit calculations for Harberger staking. + * + * Positions earn profit through their proportional share of new token issuance. + * This aligns with the Harberger tax economic model where stakers earn from protocol growth. + */ + +/** + * Calculate profit for an active position. + * + * Active positions earn their proportional share of all new tokens minted + * since the position was created. + * + * @param totalSupplyInit - Total token supply when position was created + * @param currentTotalSupply - Current total token supply + * @param positionShare - Position's proportional share (0-1) + * @returns Profit in token units (not wei) + */ +export function calculateActivePositionProfit(totalSupplyInit: bigint, currentTotalSupply: bigint, positionShare: number): number { + if (totalSupplyInit < 0n || currentTotalSupply < 0n) { + throw new Error('Supply values must be non-negative'); + } + + if (positionShare < 0 || positionShare > 1) { + throw new Error('Position share must be between 0 and 1'); + } + + if (currentTotalSupply < totalSupplyInit) { + // If supply decreased (shouldn't happen in normal operation), return 0 + return 0; + } + + // Convert to token units (assuming 18 decimals) + const initSupply = Number(totalSupplyInit) / 1e18; + const currentSupply = Number(currentTotalSupply) / 1e18; + + // Calculate new issuance since position creation + const newIssuance = currentSupply - initSupply; + + // Position earns its share of new issuance + return newIssuance * positionShare; +} + +/** + * Calculate profit for a closed position. + * + * Closed positions earned their proportional share of all new tokens minted + * during the position's lifetime (from creation to closure). + * + * @param totalSupplyInit - Total token supply when position was created + * @param totalSupplyEnd - Total token supply when position was closed + * @param positionShare - Position's proportional share (0-1) + * @returns Profit in token units (not wei) + */ +export function calculateClosedPositionProfit(totalSupplyInit: bigint, totalSupplyEnd: bigint, positionShare: number): number { + if (totalSupplyInit < 0n || totalSupplyEnd < 0n) { + throw new Error('Supply values must be non-negative'); + } + + if (positionShare < 0 || positionShare > 1) { + throw new Error('Position share must be between 0 and 1'); + } + + if (totalSupplyEnd < totalSupplyInit) { + // If supply decreased during position lifetime, return 0 + return 0; + } + + // Convert to token units (assuming 18 decimals) + const initSupply = Number(totalSupplyInit) / 1e18; + const endSupply = Number(totalSupplyEnd) / 1e18; + + // Calculate new issuance during position lifetime + const newIssuance = endSupply - initSupply; + + // Position earned its share of new issuance + return newIssuance * positionShare; +} diff --git a/kraiken-lib/src/tests/position.test.ts b/kraiken-lib/src/tests/position.test.ts new file mode 100644 index 0000000..3d5fd07 --- /dev/null +++ b/kraiken-lib/src/tests/position.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, test } from '@jest/globals'; +import { calculateActivePositionProfit, calculateClosedPositionProfit } from '../position.js'; + +describe('position profit calculations', () => { + describe('calculateActivePositionProfit', () => { + test('calculates profit correctly for active position with 10% share', () => { + const totalSupplyInit = 1000000n * 10n ** 18n; // 1M tokens + const currentTotalSupply = 1100000n * 10n ** 18n; // 1.1M tokens (100k new) + const positionShare = 0.1; // 10% + + const profit = calculateActivePositionProfit(totalSupplyInit, currentTotalSupply, positionShare); + + // Expected: (1,100,000 - 1,000,000) * 0.1 = 10,000 tokens + expect(profit).toBeCloseTo(10000, 2); + }); + + test('returns zero when no new issuance', () => { + const totalSupplyInit = 1000000n * 10n ** 18n; + const currentTotalSupply = 1000000n * 10n ** 18n; // Same supply + const positionShare = 0.5; + + const profit = calculateActivePositionProfit(totalSupplyInit, currentTotalSupply, positionShare); + + expect(profit).toBe(0); + }); + + test('returns zero when current supply is less than initial (should not happen)', () => { + const totalSupplyInit = 1000000n * 10n ** 18n; + const currentTotalSupply = 900000n * 10n ** 18n; // Decreased supply + const positionShare = 0.2; + + const profit = calculateActivePositionProfit(totalSupplyInit, currentTotalSupply, positionShare); + + expect(profit).toBe(0); + }); + + test('calculates profit correctly for small share', () => { + const totalSupplyInit = 1000000n * 10n ** 18n; + const currentTotalSupply = 1500000n * 10n ** 18n; // 500k new tokens + const positionShare = 0.001; // 0.1% + + const profit = calculateActivePositionProfit(totalSupplyInit, currentTotalSupply, positionShare); + + // Expected: 500,000 * 0.001 = 500 tokens + expect(profit).toBeCloseTo(500, 2); + }); + + test('calculates profit correctly for large share', () => { + const totalSupplyInit = 500000n * 10n ** 18n; + const currentTotalSupply = 750000n * 10n ** 18n; // 250k new tokens + const positionShare = 0.8; // 80% + + const profit = calculateActivePositionProfit(totalSupplyInit, currentTotalSupply, positionShare); + + // Expected: 250,000 * 0.8 = 200,000 tokens + expect(profit).toBeCloseTo(200000, 2); + }); + + test('handles very large supply values', () => { + const totalSupplyInit = 1000000000n * 10n ** 18n; // 1 billion tokens + const currentTotalSupply = 1100000000n * 10n ** 18n; // 100M new + const positionShare = 0.05; // 5% + + const profit = calculateActivePositionProfit(totalSupplyInit, currentTotalSupply, positionShare); + + // Expected: 100,000,000 * 0.05 = 5,000,000 tokens + expect(profit).toBeCloseTo(5000000, 2); + }); + + test('throws error for negative supply values', () => { + expect(() => { + calculateActivePositionProfit(-100n, 200n, 0.5); + }).toThrow('Supply values must be non-negative'); + + expect(() => { + calculateActivePositionProfit(100n, -200n, 0.5); + }).toThrow('Supply values must be non-negative'); + }); + + test('throws error for invalid position share', () => { + const supply = 1000n * 10n ** 18n; + + expect(() => { + calculateActivePositionProfit(supply, supply, -0.1); + }).toThrow('Position share must be between 0 and 1'); + + expect(() => { + calculateActivePositionProfit(supply, supply, 1.5); + }).toThrow('Position share must be between 0 and 1'); + }); + + test('handles edge case of 100% share', () => { + const totalSupplyInit = 100000n * 10n ** 18n; + const currentTotalSupply = 150000n * 10n ** 18n; // 50k new tokens + const positionShare = 1.0; // 100% + + const profit = calculateActivePositionProfit(totalSupplyInit, currentTotalSupply, positionShare); + + // Expected: all new issuance goes to this position + expect(profit).toBeCloseTo(50000, 2); + }); + + test('handles edge case of 0% share', () => { + const totalSupplyInit = 100000n * 10n ** 18n; + const currentTotalSupply = 150000n * 10n ** 18n; + const positionShare = 0; // 0% + + const profit = calculateActivePositionProfit(totalSupplyInit, currentTotalSupply, positionShare); + + expect(profit).toBe(0); + }); + }); + + describe('calculateClosedPositionProfit', () => { + test('calculates profit correctly for closed position with 15% share', () => { + const totalSupplyInit = 800000n * 10n ** 18n; // 800k tokens + const totalSupplyEnd = 1000000n * 10n ** 18n; // 1M tokens (200k new) + const positionShare = 0.15; // 15% + + const profit = calculateClosedPositionProfit(totalSupplyInit, totalSupplyEnd, positionShare); + + // Expected: (1,000,000 - 800,000) * 0.15 = 30,000 tokens + expect(profit).toBeCloseTo(30000, 2); + }); + + test('returns zero when no issuance during position lifetime', () => { + const totalSupplyInit = 500000n * 10n ** 18n; + const totalSupplyEnd = 500000n * 10n ** 18n; // Same supply + const positionShare = 0.3; + + const profit = calculateClosedPositionProfit(totalSupplyInit, totalSupplyEnd, positionShare); + + expect(profit).toBe(0); + }); + + test('returns zero when end supply is less than initial (should not happen)', () => { + const totalSupplyInit = 1000000n * 10n ** 18n; + const totalSupplyEnd = 900000n * 10n ** 18n; // Decreased supply + const positionShare = 0.25; + + const profit = calculateClosedPositionProfit(totalSupplyInit, totalSupplyEnd, positionShare); + + expect(profit).toBe(0); + }); + + test('calculates profit for short-lived position with rapid issuance', () => { + const totalSupplyInit = 1000000n * 10n ** 18n; + const totalSupplyEnd = 2000000n * 10n ** 18n; // Doubled during lifetime + const positionShare = 0.05; // 5% + + const profit = calculateClosedPositionProfit(totalSupplyInit, totalSupplyEnd, positionShare); + + // Expected: 1,000,000 * 0.05 = 50,000 tokens + expect(profit).toBeCloseTo(50000, 2); + }); + + test('handles very small issuance amounts', () => { + const totalSupplyInit = 1000000n * 10n ** 18n; + const totalSupplyEnd = 1000100n * 10n ** 18n; // Only 100 new tokens + const positionShare = 0.1; // 10% + + const profit = calculateClosedPositionProfit(totalSupplyInit, totalSupplyEnd, positionShare); + + // Expected: 100 * 0.1 = 10 tokens + expect(profit).toBeCloseTo(10, 2); + }); + + test('throws error for negative supply values', () => { + expect(() => { + calculateClosedPositionProfit(-100n, 200n, 0.5); + }).toThrow('Supply values must be non-negative'); + + expect(() => { + calculateClosedPositionProfit(100n, -200n, 0.5); + }).toThrow('Supply values must be non-negative'); + }); + + test('throws error for invalid position share', () => { + const supply = 1000n * 10n ** 18n; + + expect(() => { + calculateClosedPositionProfit(supply, supply, -0.1); + }).toThrow('Position share must be between 0 and 1'); + + expect(() => { + calculateClosedPositionProfit(supply, supply, 1.5); + }).toThrow('Position share must be between 0 and 1'); + }); + + test('matches active position calculation for same parameters', () => { + const totalSupplyInit = 600000n * 10n ** 18n; + const totalSupplyEnd = 900000n * 10n ** 18n; + const positionShare = 0.2; + + const closedProfit = calculateClosedPositionProfit(totalSupplyInit, totalSupplyEnd, positionShare); + const activeProfit = calculateActivePositionProfit(totalSupplyInit, totalSupplyEnd, positionShare); + + // Both should calculate the same profit for the same supply range + expect(closedProfit).toBeCloseTo(activeProfit, 2); + }); + }); + + describe('real-world scenarios', () => { + test('example from CollapseActive component (seed data)', () => { + // Based on typical seed data: ~3M initial supply, position share ~0.03 + const totalSupplyInit = 3000000n * 10n ** 18n; + const currentTotalSupply = 3150000n * 10n ** 18n; // 150k new tokens + const positionShare = 0.03; // 3% + + const profit = calculateActivePositionProfit(totalSupplyInit, currentTotalSupply, positionShare); + + // Expected: 150,000 * 0.03 = 4,500 tokens + expect(profit).toBeCloseTo(4500, 2); + }); + + test('high tax rate position with small share', () => { + const totalSupplyInit = 2500000n * 10n ** 18n; + const currentTotalSupply = 3000000n * 10n ** 18n; // 500k new + const positionShare = 0.005; // 0.5% (high tax rate = small share) + + const profit = calculateActivePositionProfit(totalSupplyInit, currentTotalSupply, positionShare); + + // Expected: 500,000 * 0.005 = 2,500 tokens + expect(profit).toBeCloseTo(2500, 2); + }); + + test('low tax rate position with large share', () => { + const totalSupplyInit = 1500000n * 10n ** 18n; + const currentTotalSupply = 1800000n * 10n ** 18n; // 300k new + const positionShare = 0.4; // 40% (low tax rate = large share) + + const profit = calculateActivePositionProfit(totalSupplyInit, currentTotalSupply, positionShare); + + // Expected: 300,000 * 0.4 = 120,000 tokens + expect(profit).toBeCloseTo(120000, 2); + }); + }); +}); diff --git a/web-app/src/components/collapse/CollapseActive.vue b/web-app/src/components/collapse/CollapseActive.vue index 2a2fb4b..2126175 100644 --- a/web-app/src/components/collapse/CollapseActive.vue +++ b/web-app/src/components/collapse/CollapseActive.vue @@ -85,8 +85,7 @@ import { type Position, loadPositions } from '@/composables/usePositions'; import { useStatCollection } from '@/composables/useStatCollection'; import { useWallet } from '@/composables/useWallet'; import { DEFAULT_CHAIN_ID } from '@/config'; - -import { formatUnits } from 'viem'; +import { calculateActivePositionProfit } from 'kraiken-lib/position'; const unstake = useUnstake(); const adjustTaxRate = useAdjustTaxRate(); @@ -150,10 +149,12 @@ async function loadActivePositionData() { //loadTotalSupply - const multiplier = Number(formatUnits(props.position.totalSupplyInit, 18)) / Number(formatUnits(statCollection.kraikenTotalSupply, 18)); - - profit.value = - Number(formatUnits(statCollection.kraikenTotalSupply, 18)) * multiplier - Number(formatUnits(statCollection.kraikenTotalSupply, 18)); + // Calculate issuance earned using kraiken-lib profit calculation + profit.value = calculateActivePositionProfit( + props.position.totalSupplyInit, + statCollection.kraikenTotalSupply, + props.position.share + ); } onMounted(() => { diff --git a/web-app/src/components/collapse/CollapseHistory.vue b/web-app/src/components/collapse/CollapseHistory.vue index c4c5b88..043e9e1 100644 --- a/web-app/src/components/collapse/CollapseHistory.vue +++ b/web-app/src/components/collapse/CollapseHistory.vue @@ -29,8 +29,7 @@ import type { Position } from '@/composables/usePositions'; import FCollapse from '@/components/fcomponents/FCollapse.vue'; import { compactNumber } from '@/utils/helper'; -import { formatUnits } from 'viem'; - +import { calculateClosedPositionProfit } from 'kraiken-lib/position'; import { computed } from 'vue'; const props = defineProps<{ taxRate: number; @@ -42,8 +41,15 @@ const props = defineProps<{ }>(); const profit = computed(() => { - const multiplier = Number(formatUnits(props.position.totalSupplyInit, 18)) / Number(formatUnits(props.position.totalSupplyEnd!, 18)); - return Number(formatUnits(props.position.totalSupplyEnd!, 18)) * multiplier - Number(formatUnits(props.position.totalSupplyEnd!, 18)); + // Calculate issuance earned using kraiken-lib profit calculation + if (!props.position.totalSupplyEnd) { + return 0; + } + return calculateClosedPositionProfit( + props.position.totalSupplyInit, + props.position.totalSupplyEnd, + props.position.share + ); }); diff --git a/web-app/src/composables/usePositions.ts b/web-app/src/composables/usePositions.ts index afe3947..fa064f9 100644 --- a/web-app/src/composables/usePositions.ts +++ b/web-app/src/composables/usePositions.ts @@ -1,11 +1,10 @@ import { ref, computed, type ComputedRef, onMounted, onUnmounted } from 'vue'; import { config } from '@/wagmi'; -import { type WatchEventReturnType, type Hex, toBytes } from 'viem'; +import { type WatchEventReturnType, type Hex } from 'viem'; import axios from 'axios'; import { getAccount, watchChainId, watchAccount, watchContractEvent, type Config } from '@wagmi/core'; import type { WatchChainIdReturnType, WatchAccountReturnType, GetAccountReturnType } from '@wagmi/core'; -import { bytesToUint256LittleEndian } from 'kraiken-lib/subgraph'; import { bigInt2Number } from '@/utils/helper'; import { getTaxRateIndexByDecimal } from '@/composables/useAdjustTaxRates'; import logger from '@/utils/logger'; @@ -43,7 +42,7 @@ const activePositions = computed(() => { return { ...obj, - positionId: formatId(obj.id as Hex), + positionId: BigInt(obj.id), amount: bigInt2Number(obj.harbDeposit, 18), taxRatePercentage: taxRateDecimal * 100, taxRate: taxRateDecimal, @@ -97,7 +96,7 @@ const myClosedPositions: ComputedRef = computed(() => { return { ...obj, - positionId: formatId(obj.id as Hex), + positionId: BigInt(obj.id), amount: obj.share * 1000000, // amount: bigInt2Number(obj.harbDeposit, 18), taxRatePercentage: taxRateDecimal * 100, @@ -189,11 +188,8 @@ export async function loadActivePositions(chainId: number, endpointOverride?: st }; } -function formatId(id: Hex) { - const bytes = toBytes(id); - const bigIntId = bytesToUint256LittleEndian(bytes); - return bigIntId; -} +// Position IDs are now directly converted to BigInt without transformation +// since GraphQL returns them as numeric strings export async function loadMyClosedPositions(chainId: number, endpointOverride: string | undefined, account: GetAccountReturnType) { const targetEndpoint = resolveGraphqlEndpoint(chainId, endpointOverride);