fix: kraiken-lib: fix broken tests + raise coverage to 95% (#286)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
cc064faa29
commit
26a9645b1f
14 changed files with 2446 additions and 2285 deletions
4155
kraiken-lib/package-lock.json
generated
4155
kraiken-lib/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -61,7 +61,8 @@
|
||||||
"/dist"
|
"/dist"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "jest",
|
"test": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"compile": "graphql-codegen",
|
"compile": "graphql-codegen",
|
||||||
"watch": "graphql-codegen -w",
|
"watch": "graphql-codegen -w",
|
||||||
"lint": "eslint 'src/**/*.ts'",
|
"lint": "eslint 'src/**/*.ts'",
|
||||||
|
|
@ -88,16 +89,15 @@
|
||||||
"@graphql-codegen/typescript": "^4.0.6",
|
"@graphql-codegen/typescript": "^4.0.6",
|
||||||
"@graphql-codegen/typescript-operations": "^4.2.0",
|
"@graphql-codegen/typescript-operations": "^4.2.0",
|
||||||
"@graphql-typed-document-node/core": "^3.2.0",
|
"@graphql-typed-document-node/core": "^3.2.0",
|
||||||
"@types/jest": "^29.5.12",
|
|
||||||
"@types/node": "^24.6.0",
|
"@types/node": "^24.6.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.45.0",
|
"@typescript-eslint/eslint-plugin": "^8.45.0",
|
||||||
"@typescript-eslint/parser": "^8.45.0",
|
"@typescript-eslint/parser": "^8.45.0",
|
||||||
|
"@vitest/coverage-v8": "^3.0.0",
|
||||||
"eslint": "^9.36.0",
|
"eslint": "^9.36.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jest": "^29.7.0",
|
|
||||||
"lint-staged": "^16.2.3",
|
"lint-staged": "^16.2.3",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"ts-jest": "^29.1.2",
|
"typescript": "^5.4.3",
|
||||||
"typescript": "^5.4.3"
|
"vitest": "^3.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ export const STAKE_ABI = StakeForgeOutput.abi;
|
||||||
* LiquidityManager events-only ABI
|
* LiquidityManager events-only ABI
|
||||||
* Tracks recenters, ETH reserve, and VWAP data
|
* Tracks recenters, ETH reserve, and VWAP data
|
||||||
*/
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
export const LiquidityManagerAbi = [
|
export const LiquidityManagerAbi = [
|
||||||
{"type":"event","name":"EthAbundance","inputs":[{"name":"currentTick","type":"int24","indexed":false},{"name":"ethBalance","type":"uint256","indexed":false},{"name":"outstandingSupply","type":"uint256","indexed":false},{"name":"vwap","type":"uint256","indexed":false},{"name":"vwapTick","type":"int24","indexed":false}],"anonymous":false},
|
{"type":"event","name":"EthAbundance","inputs":[{"name":"currentTick","type":"int24","indexed":false},{"name":"ethBalance","type":"uint256","indexed":false},{"name":"outstandingSupply","type":"uint256","indexed":false},{"name":"vwap","type":"uint256","indexed":false},{"name":"vwapTick","type":"int24","indexed":false}],"anonymous":false},
|
||||||
{"type":"event","name":"EthScarcity","inputs":[{"name":"currentTick","type":"int24","indexed":false},{"name":"ethBalance","type":"uint256","indexed":false},{"name":"outstandingSupply","type":"uint256","indexed":false},{"name":"vwap","type":"uint256","indexed":false},{"name":"vwapTick","type":"int24","indexed":false}],"anonymous":false},
|
{"type":"event","name":"EthScarcity","inputs":[{"name":"currentTick","type":"int24","indexed":false},{"name":"ethBalance","type":"uint256","indexed":false},{"name":"outstandingSupply","type":"uint256","indexed":false},{"name":"vwap","type":"uint256","indexed":false},{"name":"vwapTick","type":"int24","indexed":false}],"anonymous":false},
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { describe, expect, test } from '@jest/globals';
|
import { describe, expect, test } from 'vitest';
|
||||||
import { weiToNumber, formatWei, compactNumber, commaNumber } from '../format.js';
|
import { weiToNumber, formatWei, compactNumber, commaNumber } from '../format.js';
|
||||||
|
|
||||||
describe('weiToNumber', () => {
|
describe('weiToNumber', () => {
|
||||||
|
|
@ -33,6 +33,11 @@ describe('weiToNumber', () => {
|
||||||
test('handles string with custom decimals', () => {
|
test('handles string with custom decimals', () => {
|
||||||
expect(weiToNumber('1000000', 6)).toBe(1);
|
expect(weiToNumber('1000000', 6)).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('handles null/undefined via nullish coalescing fallback', () => {
|
||||||
|
expect(weiToNumber(null as unknown as bigint)).toBe(0);
|
||||||
|
expect(weiToNumber(undefined as unknown as bigint)).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('formatWei', () => {
|
describe('formatWei', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { describe, expect, test } from '@jest/globals';
|
import { describe, expect, test } from 'vitest';
|
||||||
import { bytesToUint256LittleEndian, uint256ToBytesLittleEndian } from '../subgraph.js';
|
import { bytesToUint256LittleEndian, uint256ToBytesLittleEndian } from '../subgraph.js';
|
||||||
|
|
||||||
describe('BigInt Conversion Functions', () => {
|
describe('BigInt Conversion Functions', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { describe, expect, test } from '@jest/globals';
|
import { describe, expect, test } from 'vitest';
|
||||||
import { decodePositionId } from '../ids.js';
|
import { decodePositionId, toBigIntId } from '../ids.js';
|
||||||
import { uint256ToBytesLittleEndian } from '../subgraph.js';
|
import { uint256ToBytesLittleEndian } from '../subgraph.js';
|
||||||
|
|
||||||
describe('ids', () => {
|
describe('decodePositionId', () => {
|
||||||
test('decodePositionId works across representations', () => {
|
test('works across bigint, hex string, and Uint8Array representations', () => {
|
||||||
const id = 12345n;
|
const id = 12345n;
|
||||||
const hex = `0x${id.toString(16)}`;
|
const hex = `0x${id.toString(16)}`;
|
||||||
const bytes = uint256ToBytesLittleEndian(id);
|
const bytes = uint256ToBytesLittleEndian(id);
|
||||||
|
|
@ -13,3 +13,40 @@ describe('ids', () => {
|
||||||
expect(decodePositionId(bytes)).toBe(id);
|
expect(decodePositionId(bytes)).toBe(id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('toBigIntId', () => {
|
||||||
|
test('returns bigint unchanged', () => {
|
||||||
|
expect(toBigIntId(42n)).toBe(42n);
|
||||||
|
expect(toBigIntId(0n)).toBe(0n);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('converts number to bigint', () => {
|
||||||
|
expect(toBigIntId(42)).toBe(42n);
|
||||||
|
expect(toBigIntId(0)).toBe(0n);
|
||||||
|
expect(toBigIntId(1000)).toBe(1000n);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('converts hex string with 0x prefix to bigint', () => {
|
||||||
|
expect(toBigIntId('0x3039')).toBe(12345n);
|
||||||
|
expect(toBigIntId('0x0')).toBe(0n);
|
||||||
|
expect(toBigIntId('0x1')).toBe(1n);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('converts string without 0x prefix (prepends 0x, treats as hex)', () => {
|
||||||
|
expect(toBigIntId('3039')).toBe(12345n);
|
||||||
|
expect(toBigIntId('0')).toBe(0n);
|
||||||
|
expect(toBigIntId('ff')).toBe(255n);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('converts Uint8Array via little endian decoding', () => {
|
||||||
|
const bytes = uint256ToBytesLittleEndian(12345n);
|
||||||
|
expect(toBigIntId(bytes)).toBe(12345n);
|
||||||
|
|
||||||
|
const zeroBytes = uint256ToBytesLittleEndian(0n);
|
||||||
|
expect(toBigIntId(zeroBytes)).toBe(0n);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws for unsupported type', () => {
|
||||||
|
expect(() => toBigIntId({} as unknown as bigint)).toThrow('Unsupported position id type');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { describe, expect, test } from '@jest/globals';
|
import { describe, expect, test } from 'vitest';
|
||||||
import { calculateActivePositionProfit, calculateClosedPositionProfit } from '../position.js';
|
import { calculateActivePositionProfit, calculateClosedPositionProfit } from '../position.js';
|
||||||
|
|
||||||
describe('position profit calculations', () => {
|
describe('position profit calculations', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,29 @@
|
||||||
import { describe, expect, test } from '@jest/globals';
|
import { describe, expect, test } from 'vitest';
|
||||||
import { getSnatchList, minimumTaxRate, selectSnatchPositions, type SnatchablePosition } from '../snatch.js';
|
import { getSnatchList, minimumTaxRate, selectSnatchPositions, type SnatchablePosition } from '../snatch.js';
|
||||||
import { uint256ToBytesLittleEndian } from '../subgraph.js';
|
import { uint256ToBytesLittleEndian } from '../subgraph.js';
|
||||||
import type { Positions as GraphPosition } from '../__generated__/graphql.js';
|
import type { Positions as GraphPosition } from '../__generated__/graphql.js';
|
||||||
|
|
||||||
describe('snatch', () => {
|
describe('minimumTaxRate', () => {
|
||||||
test('minimumTaxRate finds the lowest tax', () => {
|
test('finds the lowest tax from a list', () => {
|
||||||
const rates = [{ taxRate: 0.12 }, { taxRate: 0.05 }, { taxRate: 0.08 }];
|
const rates = [{ taxRate: 0.12 }, { taxRate: 0.05 }, { taxRate: 0.08 }];
|
||||||
expect(minimumTaxRate(rates, 1)).toBeCloseTo(0.05);
|
expect(minimumTaxRate(rates, 1)).toBeCloseTo(0.05);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns fallback for empty array', () => {
|
||||||
expect(minimumTaxRate([], 0.42)).toBe(0.42);
|
expect(minimumTaxRate([], 0.42)).toBe(0.42);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('selectSnatchPositions chooses cheapest positions first', () => {
|
test('returns fallback of 0 by default for empty array', () => {
|
||||||
|
expect(minimumTaxRate([])).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('works with a single element', () => {
|
||||||
|
expect(minimumTaxRate([{ taxRate: 0.07 }])).toBeCloseTo(0.07);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('selectSnatchPositions', () => {
|
||||||
|
test('chooses cheapest positions first', () => {
|
||||||
const candidates: SnatchablePosition[] = [
|
const candidates: SnatchablePosition[] = [
|
||||||
{ id: 1n, stakeShares: 30n, taxRate: 0.05, taxRateIndex: 5 },
|
{ id: 1n, stakeShares: 30n, taxRate: 0.05, taxRateIndex: 5 },
|
||||||
{ id: 2n, stakeShares: 40n, taxRate: 0.03, taxRateIndex: 3 },
|
{ id: 2n, stakeShares: 40n, taxRate: 0.03, taxRateIndex: 3 },
|
||||||
|
|
@ -28,7 +41,7 @@ describe('snatch', () => {
|
||||||
expect(result.maxSelectedTaxRateIndex).toBe(5);
|
expect(result.maxSelectedTaxRateIndex).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('selectSnatchPositions keeps track of remaining shortfall', () => {
|
test('keeps track of remaining shortfall when not enough candidates', () => {
|
||||||
const candidates: SnatchablePosition[] = [
|
const candidates: SnatchablePosition[] = [
|
||||||
{ id: 1n, stakeShares: 10n, taxRate: 0.01 },
|
{ id: 1n, stakeShares: 10n, taxRate: 0.01 },
|
||||||
{ id: 2n, stakeShares: 10n, taxRate: 0.02 },
|
{ id: 2n, stakeShares: 10n, taxRate: 0.02 },
|
||||||
|
|
@ -42,48 +55,151 @@ describe('snatch', () => {
|
||||||
expect(result.remainingShortfall).toBe(30n);
|
expect(result.remainingShortfall).toBe(30n);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getSnatchList converts subgraph positions', () => {
|
test('returns empty result when shortfallShares is zero', () => {
|
||||||
const stakeTotalSupply = 1_000_000n * 10n ** 18n;
|
const candidates: SnatchablePosition[] = [{ id: 1n, stakeShares: 100n, taxRate: 0.05 }];
|
||||||
|
|
||||||
const positions = [
|
const result = selectSnatchPositions(candidates, {
|
||||||
{
|
shortfallShares: 0n,
|
||||||
__typename: 'positions',
|
maxTaxRate: 0.1,
|
||||||
id: uint256ToBytesLittleEndian(1n),
|
});
|
||||||
owner: '0xowner1',
|
|
||||||
share: 0.0001,
|
expect(result.selected).toEqual([]);
|
||||||
creationTime: '0',
|
expect(result.coveredShares).toBe(0n);
|
||||||
lastTaxTime: '0',
|
expect(result.remainingShortfall).toBe(0n);
|
||||||
taxRate: 0.02,
|
});
|
||||||
status: 'Active',
|
|
||||||
createdAt: 0,
|
test('filters out candidates at or above maxTaxRate', () => {
|
||||||
kraikenDeposit: '0',
|
const candidates: SnatchablePosition[] = [
|
||||||
payout: '0',
|
{ id: 1n, stakeShares: 50n, taxRate: 0.1 }, // equal to maxTaxRate -> filtered
|
||||||
snatched: 0,
|
{ id: 2n, stakeShares: 50n, taxRate: 0.15 }, // above maxTaxRate -> filtered
|
||||||
stakeDeposit: '0',
|
{ id: 3n, stakeShares: 50n, taxRate: 0.05 }, // below maxTaxRate -> included
|
||||||
taxPaid: '0',
|
];
|
||||||
totalSupplyInit: '0',
|
|
||||||
},
|
const result = selectSnatchPositions(candidates, {
|
||||||
{
|
shortfallShares: 100n,
|
||||||
__typename: 'positions',
|
maxTaxRate: 0.1,
|
||||||
id: uint256ToBytesLittleEndian(2n),
|
});
|
||||||
owner: '0xowner2',
|
|
||||||
share: 0.0002,
|
expect(result.selected.map(p => p.id)).toEqual([3n]);
|
||||||
creationTime: '0',
|
expect(result.remainingShortfall).toBe(50n);
|
||||||
lastTaxTime: '0',
|
});
|
||||||
taxRate: 0.01,
|
|
||||||
status: 'Active',
|
test('filters out positions owned by recipient when includeOwned is false', () => {
|
||||||
createdAt: 0,
|
const candidates: SnatchablePosition[] = [
|
||||||
kraikenDeposit: '0',
|
{ id: 1n, stakeShares: 50n, taxRate: 0.05, owner: '0xABC' },
|
||||||
payout: '0',
|
{ id: 2n, stakeShares: 50n, taxRate: 0.04, owner: '0xDEF' },
|
||||||
snatched: 0,
|
];
|
||||||
stakeDeposit: '0',
|
|
||||||
taxPaid: '0',
|
const result = selectSnatchPositions(candidates, {
|
||||||
totalSupplyInit: '0',
|
shortfallShares: 100n,
|
||||||
},
|
maxTaxRate: 0.1,
|
||||||
] as unknown as GraphPosition[];
|
includeOwned: false,
|
||||||
|
recipientAddress: '0xabc', // same as owner of id=1 (case-insensitive)
|
||||||
|
});
|
||||||
|
|
||||||
|
// id=1 is owned by recipient -> filtered out
|
||||||
|
expect(result.selected.map(p => p.id)).toEqual([2n]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('includes owned positions when includeOwned is true', () => {
|
||||||
|
const candidates: SnatchablePosition[] = [
|
||||||
|
{ id: 1n, stakeShares: 50n, taxRate: 0.05, owner: '0xABC' },
|
||||||
|
{ id: 2n, stakeShares: 50n, taxRate: 0.04, owner: '0xDEF' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = selectSnatchPositions(candidates, {
|
||||||
|
shortfallShares: 100n,
|
||||||
|
maxTaxRate: 0.1,
|
||||||
|
includeOwned: true,
|
||||||
|
recipientAddress: '0xabc',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.selected.map(p => p.id)).toEqual([2n, 1n]);
|
||||||
|
expect(result.coveredShares).toBe(100n);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips candidates with zero stakeShares', () => {
|
||||||
|
const candidates: SnatchablePosition[] = [
|
||||||
|
{ id: 1n, stakeShares: 0n, taxRate: 0.01 }, // zero shares -> skipped
|
||||||
|
{ id: 2n, stakeShares: 50n, taxRate: 0.02 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = selectSnatchPositions(candidates, {
|
||||||
|
shortfallShares: 30n,
|
||||||
|
maxTaxRate: 0.05,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.selected.map(p => p.id)).toEqual([2n]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tracks maxSelectedTaxRateIndex for tie-breaking same tax rate', () => {
|
||||||
|
const candidates: SnatchablePosition[] = [
|
||||||
|
{ id: 1n, stakeShares: 20n, taxRate: 0.05, taxRateIndex: 3 },
|
||||||
|
{ id: 2n, stakeShares: 20n, taxRate: 0.05, taxRateIndex: 7 }, // higher index, same rate
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = selectSnatchPositions(candidates, {
|
||||||
|
shortfallShares: 40n,
|
||||||
|
maxTaxRate: 0.1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Both are selected; the one with higher taxRateIndex should win
|
||||||
|
expect(result.maxSelectedTaxRateIndex).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles tie-breaking when first candidate has no taxRateIndex (covers ?? -1 fallback)', () => {
|
||||||
|
const candidates: SnatchablePosition[] = [
|
||||||
|
{ id: 1n, stakeShares: 20n, taxRate: 0.05 }, // no taxRateIndex
|
||||||
|
{ id: 2n, stakeShares: 20n, taxRate: 0.05, taxRateIndex: 5 }, // same rate, has index
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = selectSnatchPositions(candidates, {
|
||||||
|
shortfallShares: 40n,
|
||||||
|
maxTaxRate: 0.1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.maxSelectedTaxRateIndex).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSnatchList', () => {
|
||||||
|
const makePosition = (id: bigint, share: number, taxRate: number, owner = '0xowner'): GraphPosition => ({
|
||||||
|
__typename: 'positions' as const,
|
||||||
|
id: uint256ToBytesLittleEndian(id) as unknown as string,
|
||||||
|
owner,
|
||||||
|
share,
|
||||||
|
creationTime: '0',
|
||||||
|
lastTaxTime: '0',
|
||||||
|
taxRate,
|
||||||
|
status: 'Active',
|
||||||
|
createdAt: 0,
|
||||||
|
kraikenDeposit: '0',
|
||||||
|
payout: '0',
|
||||||
|
snatched: 0,
|
||||||
|
stakeDeposit: '0',
|
||||||
|
taxPaid: '0',
|
||||||
|
totalSupplyInit: '0',
|
||||||
|
}) as unknown as GraphPosition;
|
||||||
|
|
||||||
|
test('converts subgraph positions and returns lowest-tax IDs', () => {
|
||||||
|
const stakeTotalSupply = 1_000_000n * 10n ** 18n;
|
||||||
|
const positions = [makePosition(1n, 0.0001, 0.02), makePosition(2n, 0.0002, 0.01)];
|
||||||
|
|
||||||
const result = getSnatchList(positions, 10n ** 18n, 0.05, stakeTotalSupply);
|
const result = getSnatchList(positions, 10n ** 18n, 0.05, stakeTotalSupply);
|
||||||
|
|
||||||
expect(result).toEqual([2n]);
|
expect(result).toEqual([2n]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('throws when stakeTotalSupply is zero', () => {
|
||||||
|
const positions = [makePosition(1n, 0.5, 0.02)];
|
||||||
|
expect(() => getSnatchList(positions, 10n, 0.05, 0n)).toThrow('stakeTotalSupply must be greater than zero');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws when there is not enough capacity to cover needed shares', () => {
|
||||||
|
// Only one small position, but need is larger than its shares
|
||||||
|
const stakeTotalSupply = 100n;
|
||||||
|
const positions = [makePosition(1n, 0.001, 0.01)]; // tiny share
|
||||||
|
|
||||||
|
expect(() => getSnatchList(positions, 1000n, 0.05, stakeTotalSupply)).toThrow('Not enough capacity');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { describe, expect, test } from '@jest/globals';
|
import { describe, expect, test } from 'vitest';
|
||||||
import { calculateSnatchShortfall, isPositionDelinquent } from '../staking.js';
|
import { calculateSnatchShortfall, isPositionDelinquent } from '../staking.js';
|
||||||
|
|
||||||
describe('staking', () => {
|
describe('calculateSnatchShortfall', () => {
|
||||||
test('calculateSnatchShortfall returns zero when within cap', () => {
|
test('returns zero when within cap', () => {
|
||||||
const outstanding = 100n;
|
const outstanding = 100n;
|
||||||
const desired = 50n;
|
const desired = 50n;
|
||||||
const total = 1000n;
|
const total = 1000n;
|
||||||
|
|
@ -11,7 +11,7 @@ describe('staking', () => {
|
||||||
expect(result).toBe(0n);
|
expect(result).toBe(0n);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('calculateSnatchShortfall returns positive remainder when exceeding cap', () => {
|
test('returns positive remainder when exceeding cap', () => {
|
||||||
const outstanding = 200n;
|
const outstanding = 200n;
|
||||||
const desired = 200n;
|
const desired = 200n;
|
||||||
const total = 1000n;
|
const total = 1000n;
|
||||||
|
|
@ -20,7 +20,29 @@ describe('staking', () => {
|
||||||
expect(result).toBe(200n);
|
expect(result).toBe(200n);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('isPositionDelinquent respects tax rate windows', () => {
|
test('returns exact overage when required equals cap + 1', () => {
|
||||||
|
// cap = (1000 * 2) / 10 = 200; required = 100 + 101 = 201; delta = 1
|
||||||
|
expect(calculateSnatchShortfall(100n, 101n, 1000n, 2n, 10n)).toBe(1n);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns zero when required equals cap exactly', () => {
|
||||||
|
// cap = (1000 * 2) / 10 = 200; required = 100 + 100 = 200; delta = 0
|
||||||
|
expect(calculateSnatchShortfall(100n, 100n, 1000n, 2n, 10n)).toBe(0n);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses default cap numerator/denominator when not provided', () => {
|
||||||
|
// defaults: capNumerator=2n, capDenominator=10n
|
||||||
|
// cap = (1000 * 2) / 10 = 200; outstanding=100, desired=50 -> required=150 <= 200 -> 0
|
||||||
|
expect(calculateSnatchShortfall(100n, 50n, 1000n)).toBe(0n);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws when capDenominator is zero', () => {
|
||||||
|
expect(() => calculateSnatchShortfall(100n, 50n, 1000n, 2n, 0n)).toThrow('capDenominator must be greater than zero');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isPositionDelinquent', () => {
|
||||||
|
test('respects tax rate windows', () => {
|
||||||
const now = 1_000_000;
|
const now = 1_000_000;
|
||||||
const taxRate = 0.5; // 50%
|
const taxRate = 0.5; // 50%
|
||||||
const windowSeconds = (365 * 24 * 60 * 60) / taxRate;
|
const windowSeconds = (365 * 24 * 60 * 60) / taxRate;
|
||||||
|
|
@ -29,4 +51,26 @@ describe('staking', () => {
|
||||||
expect(isPositionDelinquent(now - windowSeconds - 10, taxRate, now)).toBe(true);
|
expect(isPositionDelinquent(now - windowSeconds - 10, taxRate, now)).toBe(true);
|
||||||
expect(isPositionDelinquent(now, 0, now)).toBe(false);
|
expect(isPositionDelinquent(now, 0, now)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('returns false for zero tax rate', () => {
|
||||||
|
expect(isPositionDelinquent(0, 0, 1_000_000)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false for negative tax rate', () => {
|
||||||
|
expect(isPositionDelinquent(0, -0.1, 1_000_000)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns true when far past the allowance window', () => {
|
||||||
|
// taxRate=1.0 -> allowance = 31_536_000s (1 year); use now > 31_536_000
|
||||||
|
const now = 100_000_000;
|
||||||
|
const lastTax = 0;
|
||||||
|
const taxRate = 1.0; // 100% per year = 1 year window
|
||||||
|
expect(isPositionDelinquent(lastTax, taxRate, now)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses current time as default when referenceTimestamp not provided', () => {
|
||||||
|
// taxRate=1.0 -> allowance = 1 year; 2 years ago is definitely delinquent
|
||||||
|
const twoYearsAgoSeconds = Math.floor(Date.now() / 1000) - 2 * 365 * 24 * 60 * 60;
|
||||||
|
expect(isPositionDelinquent(twoYearsAgoSeconds, 1.0)).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
72
kraiken-lib/src/tests/subgraph.test.ts
Normal file
72
kraiken-lib/src/tests/subgraph.test.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
import { bytesToUint256LittleEndian, uint256ToBytesLittleEndian } from '../subgraph.js';
|
||||||
|
|
||||||
|
describe('uint256ToBytesLittleEndian', () => {
|
||||||
|
test('converts zero to four zero bytes', () => {
|
||||||
|
expect(uint256ToBytesLittleEndian(0n)).toEqual(new Uint8Array([0, 0, 0, 0]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('converts 1 to [1, 0, 0, 0]', () => {
|
||||||
|
expect(uint256ToBytesLittleEndian(1n)).toEqual(new Uint8Array([1, 0, 0, 0]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('converts 3 to [3, 0, 0, 0]', () => {
|
||||||
|
const result = uint256ToBytesLittleEndian(3n);
|
||||||
|
let hexString = '0x';
|
||||||
|
for (let i = 0; i < result.length; i++) {
|
||||||
|
hexString += result[i].toString(16).padStart(2, '0');
|
||||||
|
}
|
||||||
|
expect(hexString).toEqual('0x03000000');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('converts 256 to [0, 1, 0, 0] (second byte)', () => {
|
||||||
|
expect(uint256ToBytesLittleEndian(256n)).toEqual(new Uint8Array([0, 1, 0, 0]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('converts 65536 to [0, 0, 1, 0] (third byte)', () => {
|
||||||
|
expect(uint256ToBytesLittleEndian(65536n)).toEqual(new Uint8Array([0, 0, 1, 0]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('converts 16777216 (2^24) to [0, 0, 0, 1] (fourth byte)', () => {
|
||||||
|
expect(uint256ToBytesLittleEndian(16777216n)).toEqual(new Uint8Array([0, 0, 0, 1]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns a 4-byte Uint8Array', () => {
|
||||||
|
const result = uint256ToBytesLittleEndian(12345n);
|
||||||
|
expect(result).toBeInstanceOf(Uint8Array);
|
||||||
|
expect(result.length).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bytesToUint256LittleEndian', () => {
|
||||||
|
test('converts four zero bytes to zero', () => {
|
||||||
|
expect(bytesToUint256LittleEndian(new Uint8Array([0, 0, 0, 0]))).toBe(0n);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('converts [1, 0, 0, 0] to 1', () => {
|
||||||
|
expect(bytesToUint256LittleEndian(new Uint8Array([1, 0, 0, 0]))).toBe(1n);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('converts [0, 1, 0, 0] to 256', () => {
|
||||||
|
expect(bytesToUint256LittleEndian(new Uint8Array([0, 1, 0, 0]))).toBe(256n);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('converts [255, 255, 255, 255] to 0xffffffff', () => {
|
||||||
|
expect(bytesToUint256LittleEndian(new Uint8Array([255, 255, 255, 255]))).toBe(0xffffffffn);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns a bigint', () => {
|
||||||
|
const result = bytesToUint256LittleEndian(new Uint8Array([3, 0, 0, 0]));
|
||||||
|
expect(typeof result).toBe('bigint');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('roundtrip: uint256ToBytesLittleEndian -> bytesToUint256LittleEndian', () => {
|
||||||
|
test('roundtrip preserves value for common position IDs', () => {
|
||||||
|
const values = [0n, 1n, 2n, 3n, 100n, 255n, 256n, 12345n, 65535n, 16777215n];
|
||||||
|
for (const val of values) {
|
||||||
|
const bytes = uint256ToBytesLittleEndian(val);
|
||||||
|
expect(bytesToUint256LittleEndian(bytes)).toBe(val);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, test } from '@jest/globals';
|
import { describe, expect, test } from 'vitest';
|
||||||
import { TAX_RATE_OPTIONS } from '../taxRates.js';
|
import { TAX_RATE_OPTIONS, TAX_RATES_CHECKSUM, TAX_RATES_RAW } from '../taxRates.js';
|
||||||
|
|
||||||
describe('taxRates', () => {
|
describe('taxRates', () => {
|
||||||
test('tax rate options exported for consumers', () => {
|
test('tax rate options exported for consumers', () => {
|
||||||
|
|
@ -23,3 +23,41 @@ describe('taxRates', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('TAX_RATES_RAW', () => {
|
||||||
|
test('is a non-empty array of positive numbers', () => {
|
||||||
|
expect(Array.isArray(TAX_RATES_RAW)).toBe(true);
|
||||||
|
expect(TAX_RATES_RAW.length).toBeGreaterThan(0);
|
||||||
|
TAX_RATES_RAW.forEach(rate => {
|
||||||
|
expect(typeof rate).toBe('number');
|
||||||
|
expect(rate).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matches TAX_RATE_OPTIONS year values', () => {
|
||||||
|
expect(TAX_RATES_RAW.length).toBe(TAX_RATE_OPTIONS.length);
|
||||||
|
TAX_RATES_RAW.forEach((raw, idx) => {
|
||||||
|
expect(raw).toBe(TAX_RATE_OPTIONS[idx].year);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('first entry is 1 and last entry is 9700', () => {
|
||||||
|
expect(TAX_RATES_RAW[0]).toBe(1);
|
||||||
|
expect(TAX_RATES_RAW[TAX_RATES_RAW.length - 1]).toBe(9700);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TAX_RATES_CHECKSUM', () => {
|
||||||
|
test('is a non-empty string', () => {
|
||||||
|
expect(typeof TAX_RATES_CHECKSUM).toBe('string');
|
||||||
|
expect(TAX_RATES_CHECKSUM.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('has expected format (16 hex chars)', () => {
|
||||||
|
expect(TAX_RATES_CHECKSUM).toMatch(/^[0-9a-f]{16}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('has known value', () => {
|
||||||
|
expect(TAX_RATES_CHECKSUM).toBe('1e37f2312ef082e9');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
101
kraiken-lib/src/tests/version.test.ts
Normal file
101
kraiken-lib/src/tests/version.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
import {
|
||||||
|
COMPATIBLE_CONTRACT_VERSIONS,
|
||||||
|
KRAIKEN_LIB_VERSION,
|
||||||
|
STACK_META_ID,
|
||||||
|
getVersionMismatchError,
|
||||||
|
isCompatibleVersion,
|
||||||
|
} from '../version.js';
|
||||||
|
|
||||||
|
describe('version constants', () => {
|
||||||
|
test('KRAIKEN_LIB_VERSION is a positive integer', () => {
|
||||||
|
expect(typeof KRAIKEN_LIB_VERSION).toBe('number');
|
||||||
|
expect(KRAIKEN_LIB_VERSION).toBeGreaterThan(0);
|
||||||
|
expect(Number.isInteger(KRAIKEN_LIB_VERSION)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('STACK_META_ID is the expected string', () => {
|
||||||
|
expect(STACK_META_ID).toBe('stack-meta');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('COMPATIBLE_CONTRACT_VERSIONS is a non-empty array', () => {
|
||||||
|
expect(Array.isArray(COMPATIBLE_CONTRACT_VERSIONS)).toBe(true);
|
||||||
|
expect(COMPATIBLE_CONTRACT_VERSIONS.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('COMPATIBLE_CONTRACT_VERSIONS includes KRAIKEN_LIB_VERSION', () => {
|
||||||
|
expect(COMPATIBLE_CONTRACT_VERSIONS).toContain(KRAIKEN_LIB_VERSION);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isCompatibleVersion', () => {
|
||||||
|
test('returns true for all known compatible versions', () => {
|
||||||
|
for (const version of COMPATIBLE_CONTRACT_VERSIONS) {
|
||||||
|
expect(isCompatibleVersion(version)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false for version 0', () => {
|
||||||
|
expect(isCompatibleVersion(0)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false for a future unknown version', () => {
|
||||||
|
expect(isCompatibleVersion(9999)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false for a negative version', () => {
|
||||||
|
expect(isCompatibleVersion(-1)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getVersionMismatchError', () => {
|
||||||
|
test('returns a multi-line string for ponder context', () => {
|
||||||
|
const error = getVersionMismatchError(99, 'ponder');
|
||||||
|
expect(typeof error).toBe('string');
|
||||||
|
expect(error.includes('\n')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ponder context includes contract version number', () => {
|
||||||
|
const error = getVersionMismatchError(99, 'ponder');
|
||||||
|
expect(error).toContain('99');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ponder context includes VERSION MISMATCH header', () => {
|
||||||
|
const error = getVersionMismatchError(99, 'ponder');
|
||||||
|
expect(error).toContain('VERSION MISMATCH');
|
||||||
|
expect(error).toContain('ponder');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ponder context includes ponder-specific rebuild instructions', () => {
|
||||||
|
const error = getVersionMismatchError(99, 'ponder');
|
||||||
|
expect(error).toContain('build-kraiken-lib.sh');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('frontend context includes VERSION MISMATCH header', () => {
|
||||||
|
const error = getVersionMismatchError(99, 'frontend');
|
||||||
|
expect(error).toContain('VERSION MISMATCH');
|
||||||
|
expect(error).toContain('frontend');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('frontend context includes page refresh instruction', () => {
|
||||||
|
const error = getVersionMismatchError(99, 'frontend');
|
||||||
|
expect(error).toContain('refreshing');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('error includes the library version', () => {
|
||||||
|
const error = getVersionMismatchError(99, 'ponder');
|
||||||
|
expect(error).toContain(String(KRAIKEN_LIB_VERSION));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('error includes compatible versions list', () => {
|
||||||
|
const error = getVersionMismatchError(99, 'ponder');
|
||||||
|
const expectedVersions = COMPATIBLE_CONTRACT_VERSIONS.join(', ');
|
||||||
|
expect(error).toContain(expectedVersions);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('frontend and ponder errors differ in instructions', () => {
|
||||||
|
const ponderError = getVersionMismatchError(99, 'ponder');
|
||||||
|
const frontendError = getVersionMismatchError(99, 'frontend');
|
||||||
|
expect(ponderError).not.toBe(frontendError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -43,17 +43,17 @@ export function getVersionMismatchError(contractVersion: number, context: 'ponde
|
||||||
const instructions =
|
const instructions =
|
||||||
context === 'ponder'
|
context === 'ponder'
|
||||||
? [
|
? [
|
||||||
'1. Check if contract was upgraded',
|
'1. Check if contract was upgraded',
|
||||||
'2. Update COMPATIBLE_CONTRACT_VERSIONS in kraiken-lib/src/version.ts',
|
'2. Update COMPATIBLE_CONTRACT_VERSIONS in kraiken-lib/src/version.ts',
|
||||||
'3. Run: ./scripts/build-kraiken-lib.sh',
|
'3. Run: ./scripts/build-kraiken-lib.sh',
|
||||||
'4. Run: rm -rf services/ponder/.ponder/',
|
'4. Run: rm -rf services/ponder/.ponder/',
|
||||||
'5. Restart Ponder for full re-index',
|
'5. Restart Ponder for full re-index',
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
'1. Contact administrator - indexer may need updating',
|
'1. Contact administrator - indexer may need updating',
|
||||||
'2. Try refreshing the page',
|
'2. Try refreshing the page',
|
||||||
'3. Check if contract was recently upgraded',
|
'3. Check if contract was recently upgraded',
|
||||||
];
|
];
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
'╔════════════════════════════════════════════════════════════╗',
|
'╔════════════════════════════════════════════════════════════╗',
|
||||||
|
|
|
||||||
18
kraiken-lib/vitest.config.ts
Normal file
18
kraiken-lib/vitest.config.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
include: ['src/**/*.ts'],
|
||||||
|
exclude: ['src/tests/**', 'src/__generated__/**', 'src/abis.ts', 'src/index.ts'],
|
||||||
|
thresholds: {
|
||||||
|
lines: 95,
|
||||||
|
functions: 95,
|
||||||
|
branches: 95,
|
||||||
|
statements: 95,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue