diff --git a/kraiken-lib/package.json b/kraiken-lib/package.json index b8b092d..3949370 100644 --- a/kraiken-lib/package.json +++ b/kraiken-lib/package.json @@ -28,5 +28,47 @@ "jest": "^29.7.0", "ts-jest": "^29.1.2", "typescript": "^5.4.3" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "require": "./dist/index.js", + "import": "./dist/index.js" + }, + "./helpers": { + "types": "./dist/helpers.d.ts", + "require": "./dist/helpers.js", + "import": "./dist/helpers.js" + }, + "./ids": { + "types": "./dist/ids.d.ts", + "require": "./dist/ids.js", + "import": "./dist/ids.js" + }, + "./taxRates": { + "types": "./dist/taxRates.d.ts", + "require": "./dist/taxRates.js", + "import": "./dist/taxRates.js" + }, + "./snatch": { + "types": "./dist/snatch.d.ts", + "require": "./dist/snatch.js", + "import": "./dist/snatch.js" + }, + "./staking": { + "types": "./dist/staking.d.ts", + "require": "./dist/staking.js", + "import": "./dist/staking.js" + }, + "./subgraph": { + "types": "./dist/subgraph.d.ts", + "require": "./dist/subgraph.js", + "import": "./dist/subgraph.js" + }, + "./abis": { + "types": "./dist/abis.d.ts", + "require": "./dist/abis.js", + "import": "./dist/abis.js" + } } } diff --git a/kraiken-lib/src/helpers.ts b/kraiken-lib/src/helpers.ts index cb5dce3..4f76fbb 100644 --- a/kraiken-lib/src/helpers.ts +++ b/kraiken-lib/src/helpers.ts @@ -1,253 +1,4 @@ -import { bytesToUint256LittleEndian } from "./subgraph"; -import type { Position } from "./__generated__/graphql"; - -export interface SnatchablePosition { - id: bigint; - stakeShares: bigint; - taxRate: number; - taxRateIndex?: number; - owner?: string | null; -} - -export interface SnatchSelectionOptions { - shortfallShares: bigint; - maxTaxRate: number; - includeOwned?: boolean; - recipientAddress?: string | null; -} - -export interface SnatchSelectionResult { - selected: SnatchablePosition[]; - coveredShares: bigint; - remainingShortfall: bigint; - maxSelectedTaxRate?: number; - maxSelectedTaxRateIndex?: number; -} - -export interface TaxRateOption { - index: number; - year: number; - daily: number; - decimal: number; -} - -export const TAX_RATE_OPTIONS: TaxRateOption[] = [ - { index: 0, year: 1, daily: 0.00274, decimal: 0.01 }, - { index: 1, year: 3, daily: 0.00822, decimal: 0.03 }, - { index: 2, year: 5, daily: 0.0137, decimal: 0.05 }, - { index: 3, year: 8, daily: 0.02192, decimal: 0.08 }, - { index: 4, year: 12, daily: 0.03288, decimal: 0.12 }, - { index: 5, year: 18, daily: 0.04932, decimal: 0.18 }, - { index: 6, year: 24, daily: 0.06575, decimal: 0.24 }, - { index: 7, year: 30, daily: 0.08219, decimal: 0.3 }, - { index: 8, year: 40, daily: 0.10959, decimal: 0.4 }, - { index: 9, year: 50, daily: 0.13699, decimal: 0.5 }, - { index: 10, year: 60, daily: 0.16438, decimal: 0.6 }, - { index: 11, year: 80, daily: 0.21918, decimal: 0.8 }, - { index: 12, year: 100, daily: 0.27397, decimal: 1.0 }, - { index: 13, year: 130, daily: 0.35616, decimal: 1.3 }, - { index: 14, year: 180, daily: 0.49315, decimal: 1.8 }, - { index: 15, year: 250, daily: 0.68493, decimal: 2.5 }, - { index: 16, year: 320, daily: 0.87671, decimal: 3.2 }, - { index: 17, year: 420, daily: 1.15068, decimal: 4.2 }, - { index: 18, year: 540, daily: 1.47945, decimal: 5.4 }, - { index: 19, year: 700, daily: 1.91781, decimal: 7.0 }, - { index: 20, year: 920, daily: 2.52055, decimal: 9.2 }, - { index: 21, year: 1200, daily: 3.28767, decimal: 12.0 }, - { index: 22, year: 1600, daily: 4.38356, decimal: 16.0 }, - { index: 23, year: 2000, daily: 5.47945, decimal: 20.0 }, - { index: 24, year: 2600, daily: 7.12329, decimal: 26.0 }, - { index: 25, year: 3400, daily: 9.31507, decimal: 34.0 }, - { index: 26, year: 4400, daily: 12.05479, decimal: 44.0 }, - { index: 27, year: 5700, daily: 15.61644, decimal: 57.0 }, - { index: 28, year: 7500, daily: 20.54795, decimal: 75.0 }, - { index: 29, year: 9700, daily: 26.57534, decimal: 97.0 }, -]; - -const SECONDS_IN_YEAR = 365 * 24 * 60 * 60; - -function normaliseAddress(value?: string | null): string { - return (value ?? "").toLowerCase(); -} - -export function calculateSnatchShortfall( - outstandingStake: bigint, - desiredStakeShares: bigint, - stakeTotalSupply: bigint, - capNumerator: bigint = 2n, - capDenominator: bigint = 10n -): bigint { - if (capDenominator === 0n) { - throw new Error("capDenominator must be greater than zero"); - } - - const cap = (stakeTotalSupply * capNumerator) / capDenominator; - const required = outstandingStake + desiredStakeShares; - const delta = required - cap; - return delta > 0n ? delta : 0n; -} - -export function minimumTaxRate( - positions: T[], - fallback: number = 0 -): number { - if (!positions.length) return fallback; - return positions.reduce( - (min, position) => (position.taxRate < min ? position.taxRate : min), - Number.POSITIVE_INFINITY - ); -} - -export function selectSnatchPositions( - candidates: SnatchablePosition[], - options: SnatchSelectionOptions -): SnatchSelectionResult { - const { - shortfallShares, - maxTaxRate, - includeOwned = false, - recipientAddress = null, - } = options; - - if (shortfallShares <= 0n) { - return { - selected: [], - coveredShares: 0n, - remainingShortfall: 0n, - }; - } - - const recipientNormalised = normaliseAddress(recipientAddress); - - const filtered = candidates.filter((candidate) => { - if (candidate.taxRate >= maxTaxRate) { - return false; - } - - if (!includeOwned && candidate.owner) { - return normaliseAddress(candidate.owner) !== recipientNormalised; - } - - return true; - }); - - const sorted = filtered.slice().sort((a, b) => { - if (a.taxRate === b.taxRate) { - return Number(a.stakeShares - b.stakeShares); - } - return a.taxRate - b.taxRate; - }); - - const selection: SnatchablePosition[] = []; - let remaining = shortfallShares; - let covered = 0n; - let maxSelectedTaxRate: number | undefined; - let maxSelectedTaxRateIndex: number | undefined; - - for (const candidate of sorted) { - if (remaining <= 0n) break; - if (candidate.stakeShares <= 0n) continue; - - selection.push(candidate); - remaining -= candidate.stakeShares; - if (remaining < 0n) { - covered = shortfallShares; - remaining = 0n; - } else { - covered = shortfallShares - remaining; - } - - if ( - maxSelectedTaxRate === undefined || - candidate.taxRate > maxSelectedTaxRate - ) { - maxSelectedTaxRate = candidate.taxRate; - if (typeof candidate.taxRateIndex === "number") { - maxSelectedTaxRateIndex = candidate.taxRateIndex; - } - } else if ( - candidate.taxRate === maxSelectedTaxRate && - typeof candidate.taxRateIndex === "number" && - candidate.taxRateIndex > (maxSelectedTaxRateIndex ?? -1) - ) { - maxSelectedTaxRateIndex = candidate.taxRateIndex; - } - } - - return { - selected: selection, - coveredShares: covered, - remainingShortfall: remaining > 0n ? remaining : 0n, - maxSelectedTaxRate, - maxSelectedTaxRateIndex, - }; -} - -export function toBigIntId(id: string | Uint8Array | number | bigint): bigint { - if (typeof id === "bigint") return id; - if (typeof id === "number") return BigInt(id); - if (typeof id === "string") { - const trimmed = id.startsWith("0x") ? id : `0x${id}`; - return BigInt(trimmed); - } - if (id instanceof Uint8Array) { - return bytesToUint256LittleEndian(id); - } - throw new Error("Unsupported position id type"); -} - -export function decodePositionId(id: string | Uint8Array | number | bigint): bigint { - return toBigIntId(id); -} - -export function isPositionDelinquent( - lastTaxTimestamp: number, - taxRate: number, - referenceTimestamp: number = Math.floor(Date.now() / 1000), - secondsInYear: number = SECONDS_IN_YEAR -): boolean { - const rate = Number(taxRate); - if (rate <= 0) return false; - - const allowance = secondsInYear / rate; - return referenceTimestamp - lastTaxTimestamp > allowance; -} - -const SHARE_SCALE = 1_000_000n; - -export function getSnatchList( - positions: Position[], - needed: bigint, - taxRate: number, - stakeTotalSupply: bigint -): bigint[] { - if (stakeTotalSupply <= 0n) { - throw new Error("stakeTotalSupply must be greater than zero"); - } - - const candidates: SnatchablePosition[] = positions.map((position) => { - const shareRatio = Number(position.share); - const scaledShares = BigInt( - Math.round(shareRatio * Number(SHARE_SCALE)) - ); - - return { - id: toBigIntId(position.id), - owner: position.owner, - stakeShares: (stakeTotalSupply * scaledShares) / SHARE_SCALE, - taxRate: Number(position.taxRate), - }; - }); - - const result = selectSnatchPositions(candidates, { - shortfallShares: needed, - maxTaxRate: taxRate, - includeOwned: true, - }); - - if (result.remainingShortfall > 0n) { - throw new Error("Not enough capacity"); - } - - return result.selected.map((candidate) => candidate.id); -} +export * from "./staking"; +export * from "./snatch"; +export * from "./ids"; +export * from "./taxRates"; diff --git a/kraiken-lib/src/ids.ts b/kraiken-lib/src/ids.ts new file mode 100644 index 0000000..09cd654 --- /dev/null +++ b/kraiken-lib/src/ids.ts @@ -0,0 +1,20 @@ +import { bytesToUint256LittleEndian } from "./subgraph"; + +export function toBigIntId(id: string | Uint8Array | number | bigint): bigint { + if (typeof id === "bigint") return id; + if (typeof id === "number") return BigInt(id); + if (typeof id === "string") { + const trimmed = id.startsWith("0x") ? id : `0x${id}`; + return BigInt(trimmed); + } + if (id instanceof Uint8Array) { + return bytesToUint256LittleEndian(id); + } + throw new Error("Unsupported position id type"); +} + +export function decodePositionId( + id: string | Uint8Array | number | bigint +): bigint { + return toBigIntId(id); +} diff --git a/kraiken-lib/src/index.ts b/kraiken-lib/src/index.ts index ae85fc5..d165b82 100644 --- a/kraiken-lib/src/index.ts +++ b/kraiken-lib/src/index.ts @@ -9,18 +9,22 @@ export { uint256ToBytesLittleEndian as uint256ToBytes, } from "./subgraph"; +export { TAX_RATE_OPTIONS, type TaxRateOption } from "./taxRates"; + export { - TAX_RATE_OPTIONS, calculateSnatchShortfall, + isPositionDelinquent, +} from "./staking"; + +export { minimumTaxRate, selectSnatchPositions, + getSnatchList, type SnatchablePosition, type SnatchSelectionOptions, type SnatchSelectionResult, - type TaxRateOption, - decodePositionId, - getSnatchList, - isPositionDelinquent, -} from "./helpers"; +} from "./snatch"; + +export { decodePositionId } from "./ids"; export { KraikenAbi, StakeAbi, ABIS } from "./abis"; diff --git a/kraiken-lib/src/snatch.ts b/kraiken-lib/src/snatch.ts new file mode 100644 index 0000000..9266df5 --- /dev/null +++ b/kraiken-lib/src/snatch.ts @@ -0,0 +1,164 @@ +import type { Position } from "./__generated__/graphql"; +import { toBigIntId } from "./ids"; + +export interface SnatchablePosition { + id: bigint; + stakeShares: bigint; + taxRate: number; + taxRateIndex?: number; + owner?: string | null; +} + +export interface SnatchSelectionOptions { + shortfallShares: bigint; + maxTaxRate: number; + includeOwned?: boolean; + recipientAddress?: string | null; +} + +export interface SnatchSelectionResult { + selected: SnatchablePosition[]; + coveredShares: bigint; + remainingShortfall: bigint; + maxSelectedTaxRate?: number; + maxSelectedTaxRateIndex?: number; +} + +const SHARE_SCALE = 1_000_000n; + +function normaliseAddress(value?: string | null): string { + return (value ?? "").toLowerCase(); +} + +export function minimumTaxRate( + positions: T[], + fallback: number = 0 +): number { + if (!positions.length) return fallback; + return positions.reduce( + (min, position) => (position.taxRate < min ? position.taxRate : min), + Number.POSITIVE_INFINITY + ); +} + +export function selectSnatchPositions( + candidates: SnatchablePosition[], + options: SnatchSelectionOptions +): SnatchSelectionResult { + const { + shortfallShares, + maxTaxRate, + includeOwned = false, + recipientAddress = null, + } = options; + + if (shortfallShares <= 0n) { + return { + selected: [], + coveredShares: 0n, + remainingShortfall: 0n, + }; + } + + const recipientNormalised = normaliseAddress(recipientAddress); + + const filtered = candidates.filter((candidate) => { + if (candidate.taxRate >= maxTaxRate) { + return false; + } + + if (!includeOwned && candidate.owner) { + return normaliseAddress(candidate.owner) !== recipientNormalised; + } + + return true; + }); + + const sorted = filtered.slice().sort((a, b) => { + if (a.taxRate === b.taxRate) { + return Number(a.stakeShares - b.stakeShares); + } + return a.taxRate - b.taxRate; + }); + + const selection: SnatchablePosition[] = []; + let remaining = shortfallShares; + let covered = 0n; + let maxSelectedTaxRate: number | undefined; + let maxSelectedTaxRateIndex: number | undefined; + + for (const candidate of sorted) { + if (remaining <= 0n) break; + if (candidate.stakeShares <= 0n) continue; + + selection.push(candidate); + remaining -= candidate.stakeShares; + if (remaining < 0n) { + covered = shortfallShares; + remaining = 0n; + } else { + covered = shortfallShares - remaining; + } + + if ( + maxSelectedTaxRate === undefined || + candidate.taxRate > maxSelectedTaxRate + ) { + maxSelectedTaxRate = candidate.taxRate; + if (typeof candidate.taxRateIndex === "number") { + maxSelectedTaxRateIndex = candidate.taxRateIndex; + } + } else if ( + candidate.taxRate === maxSelectedTaxRate && + typeof candidate.taxRateIndex === "number" && + candidate.taxRateIndex > (maxSelectedTaxRateIndex ?? -1) + ) { + maxSelectedTaxRateIndex = candidate.taxRateIndex; + } + } + + return { + selected: selection, + coveredShares: covered, + remainingShortfall: remaining > 0n ? remaining : 0n, + maxSelectedTaxRate, + maxSelectedTaxRateIndex, + }; +} + +export function getSnatchList( + positions: Position[], + needed: bigint, + taxRate: number, + stakeTotalSupply: bigint +): bigint[] { + if (stakeTotalSupply <= 0n) { + throw new Error("stakeTotalSupply must be greater than zero"); + } + + const candidates: SnatchablePosition[] = positions.map((position) => { + const shareRatio = Number(position.share); + const scaledShares = BigInt( + Math.round(shareRatio * Number(SHARE_SCALE)) + ); + + return { + id: toBigIntId(position.id), + owner: position.owner, + stakeShares: (stakeTotalSupply * scaledShares) / SHARE_SCALE, + taxRate: Number(position.taxRate), + }; + }); + + const result = selectSnatchPositions(candidates, { + shortfallShares: needed, + maxTaxRate: taxRate, + includeOwned: true, + }); + + if (result.remainingShortfall > 0n) { + throw new Error("Not enough capacity"); + } + + return result.selected.map((candidate) => candidate.id); +} diff --git a/kraiken-lib/src/staking.ts b/kraiken-lib/src/staking.ts new file mode 100644 index 0000000..99476eb --- /dev/null +++ b/kraiken-lib/src/staking.ts @@ -0,0 +1,31 @@ +const SECONDS_IN_YEAR = 365 * 24 * 60 * 60; + +export function calculateSnatchShortfall( + outstandingStake: bigint, + desiredStakeShares: bigint, + stakeTotalSupply: bigint, + capNumerator: bigint = 2n, + capDenominator: bigint = 10n +): bigint { + if (capDenominator === 0n) { + throw new Error("capDenominator must be greater than zero"); + } + + const cap = (stakeTotalSupply * capNumerator) / capDenominator; + const required = outstandingStake + desiredStakeShares; + const delta = required - cap; + return delta > 0n ? delta : 0n; +} + +export function isPositionDelinquent( + lastTaxTimestamp: number, + taxRate: number, + referenceTimestamp: number = Math.floor(Date.now() / 1000), + secondsInYear: number = SECONDS_IN_YEAR +): boolean { + const rate = Number(taxRate); + if (rate <= 0) return false; + + const allowance = secondsInYear / rate; + return referenceTimestamp - lastTaxTimestamp > allowance; +} diff --git a/kraiken-lib/src/taxRates.ts b/kraiken-lib/src/taxRates.ts new file mode 100644 index 0000000..df0dc55 --- /dev/null +++ b/kraiken-lib/src/taxRates.ts @@ -0,0 +1,39 @@ +export interface TaxRateOption { + index: number; + year: number; + daily: number; + decimal: number; +} + +export const TAX_RATE_OPTIONS: TaxRateOption[] = [ + { index: 0, year: 1, daily: 0.00274, decimal: 0.01 }, + { index: 1, year: 3, daily: 0.00822, decimal: 0.03 }, + { index: 2, year: 5, daily: 0.0137, decimal: 0.05 }, + { index: 3, year: 8, daily: 0.02192, decimal: 0.08 }, + { index: 4, year: 12, daily: 0.03288, decimal: 0.12 }, + { index: 5, year: 18, daily: 0.04932, decimal: 0.18 }, + { index: 6, year: 24, daily: 0.06575, decimal: 0.24 }, + { index: 7, year: 30, daily: 0.08219, decimal: 0.3 }, + { index: 8, year: 40, daily: 0.10959, decimal: 0.4 }, + { index: 9, year: 50, daily: 0.13699, decimal: 0.5 }, + { index: 10, year: 60, daily: 0.16438, decimal: 0.6 }, + { index: 11, year: 80, daily: 0.21918, decimal: 0.8 }, + { index: 12, year: 100, daily: 0.27397, decimal: 1.0 }, + { index: 13, year: 130, daily: 0.35616, decimal: 1.3 }, + { index: 14, year: 180, daily: 0.49315, decimal: 1.8 }, + { index: 15, year: 250, daily: 0.68493, decimal: 2.5 }, + { index: 16, year: 320, daily: 0.87671, decimal: 3.2 }, + { index: 17, year: 420, daily: 1.15068, decimal: 4.2 }, + { index: 18, year: 540, daily: 1.47945, decimal: 5.4 }, + { index: 19, year: 700, daily: 1.91781, decimal: 7.0 }, + { index: 20, year: 920, daily: 2.52055, decimal: 9.2 }, + { index: 21, year: 1200, daily: 3.28767, decimal: 12.0 }, + { index: 22, year: 1600, daily: 4.38356, decimal: 16.0 }, + { index: 23, year: 2000, daily: 5.47945, decimal: 20.0 }, + { index: 24, year: 2600, daily: 7.12329, decimal: 26.0 }, + { index: 25, year: 3400, daily: 9.31507, decimal: 34.0 }, + { index: 26, year: 4400, daily: 12.05479, decimal: 44.0 }, + { index: 27, year: 5700, daily: 15.61644, decimal: 57.0 }, + { index: 28, year: 7500, daily: 20.54795, decimal: 75.0 }, + { index: 29, year: 9700, daily: 26.57534, decimal: 97.0 }, +]; diff --git a/kraiken-lib/src/tests/functions.test.ts b/kraiken-lib/src/tests/functions.test.ts index 351b5c4..d04e601 100644 --- a/kraiken-lib/src/tests/functions.test.ts +++ b/kraiken-lib/src/tests/functions.test.ts @@ -1,3 +1,4 @@ +import { describe, expect, test } from "@jest/globals"; import { bytesToUint256LittleEndian, uint256ToBytesLittleEndian } from "../subgraph"; import { Position, PositionStatus } from '../__generated__/graphql'; diff --git a/kraiken-lib/src/tests/ids.test.ts b/kraiken-lib/src/tests/ids.test.ts new file mode 100644 index 0000000..5dd857d --- /dev/null +++ b/kraiken-lib/src/tests/ids.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "@jest/globals"; +import { decodePositionId } from "../ids"; +import { uint256ToBytesLittleEndian } from "../subgraph"; + +describe("ids", () => { + test("decodePositionId works across representations", () => { + const id = 12345n; + const hex = `0x${id.toString(16)}`; + const bytes = uint256ToBytesLittleEndian(id); + + expect(decodePositionId(id)).toBe(id); + expect(decodePositionId(hex)).toBe(id); + expect(decodePositionId(bytes)).toBe(id); + }); +}); diff --git a/kraiken-lib/src/tests/helpers.test.ts b/kraiken-lib/src/tests/snatch.test.ts similarity index 58% rename from kraiken-lib/src/tests/helpers.test.ts rename to kraiken-lib/src/tests/snatch.test.ts index 0f1a2a6..1f82aac 100644 --- a/kraiken-lib/src/tests/helpers.test.ts +++ b/kraiken-lib/src/tests/snatch.test.ts @@ -1,36 +1,15 @@ +import { describe, expect, test } from "@jest/globals"; import { - TAX_RATE_OPTIONS, - calculateSnatchShortfall, - decodePositionId, getSnatchList, - isPositionDelinquent, minimumTaxRate, selectSnatchPositions, type SnatchablePosition, -} from "../helpers"; +} from "../snatch"; import { uint256ToBytesLittleEndian } from "../subgraph"; import type { Position } from "../__generated__/graphql"; import { PositionStatus } from "../__generated__/graphql"; -describe("helpers", () => { - test("calculateSnatchShortfall returns zero when within cap", () => { - const outstanding = 100n; - const desired = 50n; - const total = 1000n; - - const result = calculateSnatchShortfall(outstanding, desired, total, 2n, 10n); - expect(result).toBe(0n); - }); - - test("calculateSnatchShortfall returns positive remainder when exceeding cap", () => { - const outstanding = 200n; - const desired = 200n; - const total = 1000n; - - const result = calculateSnatchShortfall(outstanding, desired, total, 2n, 10n); - expect(result).toBe(200n); - }); - +describe("snatch", () => { test("minimumTaxRate finds the lowest tax", () => { const rates = [{ taxRate: 0.12 }, { taxRate: 0.05 }, { taxRate: 0.08 }]; expect(minimumTaxRate(rates, 1)).toBeCloseTo(0.05); @@ -69,30 +48,6 @@ describe("helpers", () => { expect(result.remainingShortfall).toBe(30n); }); - test("isPositionDelinquent respects tax rate windows", () => { - const now = 1_000_000; - const taxRate = 0.5; // 50% - const windowSeconds = (365 * 24 * 60 * 60) / taxRate; - - expect( - isPositionDelinquent(now - windowSeconds + 1, taxRate, now) - ).toBe(false); - expect( - isPositionDelinquent(now - windowSeconds - 10, taxRate, now) - ).toBe(true); - expect(isPositionDelinquent(now, 0, now)).toBe(false); - }); - - test("decodePositionId works across representations", () => { - const id = 12345n; - const hex = `0x${id.toString(16)}`; - const bytes = uint256ToBytesLittleEndian(id); - - expect(decodePositionId(id)).toBe(id); - expect(decodePositionId(hex)).toBe(id); - expect(decodePositionId(bytes)).toBe(id); - }); - test("getSnatchList converts subgraph positions", () => { const stakeTotalSupply = 1_000_000n * 10n ** 18n; @@ -123,11 +78,4 @@ describe("helpers", () => { expect(result).toEqual([2n]); }); - - test("tax rate options exported for consumers", () => { - expect(TAX_RATE_OPTIONS.length).toBeGreaterThan(0); - expect(TAX_RATE_OPTIONS[0]).toEqual( - expect.objectContaining({ index: 0, year: 1, decimal: 0.01 }) - ); - }); }); diff --git a/kraiken-lib/src/tests/staking.test.ts b/kraiken-lib/src/tests/staking.test.ts new file mode 100644 index 0000000..0422eec --- /dev/null +++ b/kraiken-lib/src/tests/staking.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "@jest/globals"; +import { calculateSnatchShortfall, isPositionDelinquent } from "../staking"; + +describe("staking", () => { + test("calculateSnatchShortfall returns zero when within cap", () => { + const outstanding = 100n; + const desired = 50n; + const total = 1000n; + + const result = calculateSnatchShortfall(outstanding, desired, total, 2n, 10n); + expect(result).toBe(0n); + }); + + test("calculateSnatchShortfall returns positive remainder when exceeding cap", () => { + const outstanding = 200n; + const desired = 200n; + const total = 1000n; + + const result = calculateSnatchShortfall(outstanding, desired, total, 2n, 10n); + expect(result).toBe(200n); + }); + + test("isPositionDelinquent respects tax rate windows", () => { + const now = 1_000_000; + const taxRate = 0.5; // 50% + const windowSeconds = (365 * 24 * 60 * 60) / taxRate; + + expect(isPositionDelinquent(now - windowSeconds + 1, taxRate, now)).toBe(false); + expect(isPositionDelinquent(now - windowSeconds - 10, taxRate, now)).toBe(true); + expect(isPositionDelinquent(now, 0, now)).toBe(false); + }); +}); diff --git a/kraiken-lib/src/tests/taxRates.test.ts b/kraiken-lib/src/tests/taxRates.test.ts new file mode 100644 index 0000000..aca5b67 --- /dev/null +++ b/kraiken-lib/src/tests/taxRates.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, test } from "@jest/globals"; +import { TAX_RATE_OPTIONS } from "../taxRates"; + +describe("taxRates", () => { + test("tax rate options exported for consumers", () => { + expect(TAX_RATE_OPTIONS.length).toBeGreaterThan(0); + expect(TAX_RATE_OPTIONS[0]).toEqual( + expect.objectContaining({ index: 0, year: 1, decimal: 0.01 }) + ); + }); +}); diff --git a/services/txnBot/service.js b/services/txnBot/service.js index ec66a14..8f9d1e3 100644 --- a/services/txnBot/service.js +++ b/services/txnBot/service.js @@ -6,7 +6,8 @@ const dotenvPath = process.env.TXN_BOT_ENV_FILE require('dotenv').config({ path: dotenvPath }); const { ethers } = require('ethers'); const express = require('express'); -const { decodePositionId, isPositionDelinquent } = require('kraiken-lib'); +const { decodePositionId } = require('kraiken-lib/ids'); +const { isPositionDelinquent } = require('kraiken-lib/staking'); const ACTIVE_POSITIONS_QUERY = ` query ActivePositions { diff --git a/web-app/src/composables/__tests__/useSnatchSelection.spec.ts b/web-app/src/composables/__tests__/useSnatchSelection.spec.ts index a62aa9e..35e31fd 100644 --- a/web-app/src/composables/__tests__/useSnatchSelection.spec.ts +++ b/web-app/src/composables/__tests__/useSnatchSelection.spec.ts @@ -28,10 +28,13 @@ vi.mock('../useAdjustTaxRates', () => ({ useAdjustTaxRate: vi.fn() })) -vi.mock('kraiken-lib', () => ({ +vi.mock('kraiken-lib/staking', () => ({ calculateSnatchShortfall: vi.fn((outstandingStake, stakingShares, stakeTotalSupply) => { return stakingShares > outstandingStake ? 0n : outstandingStake - stakingShares - }), + }) +})) + +vi.mock('kraiken-lib/snatch', () => ({ selectSnatchPositions: vi.fn((candidates, options) => { if (candidates.length === 0) { return { selected: [], remainingShortfall: options.shortfallShares } @@ -271,4 +274,4 @@ describe('useSnatchSelection', () => { // Position has taxRateIndex: 1, so nextIndex = 2, taxRates[2] = { year: 3 } expect(floorTax.value).toBe(3) }) -}) \ No newline at end of file +}) diff --git a/web-app/src/composables/useAdjustTaxRates.ts b/web-app/src/composables/useAdjustTaxRates.ts index e620400..4c65015 100644 --- a/web-app/src/composables/useAdjustTaxRates.ts +++ b/web-app/src/composables/useAdjustTaxRates.ts @@ -4,7 +4,7 @@ import { waitForTransactionReceipt } from "@wagmi/core"; import { config } from "@/wagmi"; import { compactNumber, formatBigIntDivision } from "@/utils/helper"; import { useContractToast } from "./useContractToast"; -import { TAX_RATE_OPTIONS } from "kraiken-lib"; +import { TAX_RATE_OPTIONS } from "kraiken-lib/taxRates"; const contractToast = useContractToast(); diff --git a/web-app/src/composables/useSnatchSelection.ts b/web-app/src/composables/useSnatchSelection.ts index d1a5f12..9110be3 100644 --- a/web-app/src/composables/useSnatchSelection.ts +++ b/web-app/src/composables/useSnatchSelection.ts @@ -4,12 +4,12 @@ import { useStake } from './useStake' import { useWallet } from './useWallet' import { useStatCollection } from './useStatCollection' import { useAdjustTaxRate } from './useAdjustTaxRates' +import { calculateSnatchShortfall } from 'kraiken-lib/staking' import { - calculateSnatchShortfall, selectSnatchPositions, minimumTaxRate, type SnatchablePosition, -} from 'kraiken-lib' +} from 'kraiken-lib/snatch' /** * Converts Kraiken token assets to shares using the same formula as Stake.sol: @@ -177,4 +177,4 @@ export function useSnatchSelection(demo = false) { floorTax, openPositionsAvailable, } -} \ No newline at end of file +}