fix(web-app): position ID, issuance earned (#96)
Bug #1: Position ID Transformation Issue (#95) Problem: Frontend applied incorrect byte conversion to position IDs, causing transactions to fail with "NoPermission" errors. Root Cause: formatId() function did little-endian byte conversion on already-correct numeric strings from GraphQL. Fix: Direct conversion BigInt(obj.id) instead of formatId(obj.id as Hex) in usePositions.ts. Result: Users can now successfully stake/unstake positions. --- Bug #2: Issuance Earned Calculation Error (#97) Problem: Frontend showed negative "Issuance Earned" values (e.g., -4,991 KRK) due to wrong mathematical formula. Root Cause: Formula calculated position.totalSupplyInit - currentTotalSupply (always negative when supply increases). Fix: Correct formula (currentTotalSupply - position.totalSupplyInit) × position.share in Vue components. Result: Shows realistic positive earnings and enables proper economic monitoring. Co-authored-by: steve <steve@harberg.dev> Co-authored-by: openhands <openhands@all-hands.dev> Reviewed-on: https://codeberg.org/johba/harb/pulls/96 Co-authored-by: traddoo <traddoo@noreply.codeberg.org> Co-committed-by: traddoo <traddoo@noreply.codeberg.org>
This commit is contained in:
parent
a555a2fdd1
commit
beefe22f90
6 changed files with 343 additions and 19 deletions
|
|
@ -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": [
|
||||
|
|
|
|||
78
kraiken-lib/src/position.ts
Normal file
78
kraiken-lib/src/position.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
238
kraiken-lib/src/tests/position.test.ts
Normal file
238
kraiken-lib/src/tests/position.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Position[]> = 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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue