lint/lib (#49)

resolves #42

Co-authored-by: johba <johba@harb.eth>
Reviewed-on: https://codeberg.org/johba/harb/pulls/49
This commit is contained in:
johba 2025-10-03 11:57:01 +02:00
parent 4f7cebda56
commit 09c36f2c87
19 changed files with 2340 additions and 236 deletions

8
kraiken-lib/.prettierrc Normal file
View file

@ -0,0 +1,8 @@
{
"semi": true,
"singleQuote": true,
"printWidth": 140,
"tabWidth": 2,
"trailingComma": "es5",
"arrowParens": "avoid"
}

View file

@ -0,0 +1,29 @@
import tsPlugin from "@typescript-eslint/eslint-plugin";
import tsParser from "@typescript-eslint/parser";
export default [
{
files: ["src/**/*.ts"],
ignores: ["src/tests/**/*", "src/__generated__/**/*"],
languageOptions: {
parser: tsParser,
parserOptions: { project: "./tsconfig.json" }
},
plugins: { "@typescript-eslint": tsPlugin },
rules: {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
"@typescript-eslint/naming-convention": [
"error",
{ selector: "function", format: ["camelCase"] },
{ selector: "variable", format: ["camelCase", "UPPER_CASE"] },
{ selector: "typeLike", format: ["PascalCase"] }
],
"max-len": ["error", { code: 140, ignoreStrings: true, ignoreTemplateLiterals: true }],
"no-console": "error",
"semi": ["error", "always"],
"indent": ["error", 2, { "SwitchCase": 1 }],
"prefer-const": "error"
}
}
];

File diff suppressed because it is too large Load diff

View file

@ -5,37 +5,6 @@
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"files": [
"/dist"
],
"scripts": {
"test": "jest",
"compile": "graphql-codegen",
"watch": "graphql-codegen -w"
},
"dependencies": {
"@apollo/client": "^3.9.10",
"graphql": "^16.8.1",
"graphql-tag": "^2.12.6"
},
"devDependencies": {
"@graphql-codegen/cli": "^5.0.2",
"@graphql-codegen/client-preset": "^4.2.5",
"@graphql-codegen/typescript": "^4.0.6",
"@graphql-codegen/typescript-operations": "^4.2.0",
"@graphql-typed-document-node/core": "^3.2.0",
"@types/jest": "^29.5.12",
"@types/node": "^24.6.0",
"jest": "^29.7.0",
"ts-jest": "^29.1.2",
"typescript": "^5.4.3"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
@ -77,5 +46,47 @@
"require": "./dist/abis.js",
"import": "./dist/abis.js"
}
},
"files": [
"/dist"
],
"scripts": {
"test": "jest",
"compile": "graphql-codegen",
"watch": "graphql-codegen -w",
"lint": "eslint 'src/**/*.ts'",
"lint:fix": "eslint 'src/**/*.ts' --fix",
"format": "prettier --write 'src/**/*.ts'",
"format:check": "prettier --check 'src/**/*.ts'",
"prepare": "husky install"
},
"lint-staged": {
"src/**/*.ts": [
"eslint --fix",
"prettier --write"
]
},
"dependencies": {
"@apollo/client": "^3.9.10",
"graphql": "^16.8.1",
"graphql-tag": "^2.12.6"
},
"devDependencies": {
"@graphql-codegen/cli": "^5.0.2",
"@graphql-codegen/client-preset": "^4.2.5",
"@graphql-codegen/typescript": "^4.0.6",
"@graphql-codegen/typescript-operations": "^4.2.0",
"@graphql-typed-document-node/core": "^3.2.0",
"@types/jest": "^29.5.12",
"@types/node": "^24.6.0",
"@typescript-eslint/eslint-plugin": "^8.45.0",
"@typescript-eslint/parser": "^8.45.0",
"eslint": "^9.36.0",
"husky": "^9.1.7",
"jest": "^29.7.0",
"lint-staged": "^16.2.3",
"prettier": "^3.6.2",
"ts-jest": "^29.1.2",
"typescript": "^5.4.3"
}
}

View file

@ -7,14 +7,14 @@ export type MakeEmpty<T extends { [key: string]: unknown }, K extends keyof T> =
export type Incremental<T> = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never };
/** All built-in and custom scalars, mapped to their actual values */
export type Scalars = {
ID: { input: string; output: string; }
String: { input: string; output: string; }
Boolean: { input: boolean; output: boolean; }
Int: { input: number; output: number; }
Float: { input: number; output: number; }
BigInt: { input: string; output: string; }
ID: { input: string; output: string };
String: { input: string; output: string };
Boolean: { input: boolean; output: boolean };
Int: { input: number; output: number };
Float: { input: number; output: number };
BigInt: { input: string; output: string };
/** The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). */
JSON: { input: any; output: any; }
JSON: { input: any; output: any };
};
export type Meta = {
@ -39,12 +39,10 @@ export type Query = {
statss: StatsPage;
};
export type QueryPositionsArgs = {
id: Scalars['String']['input'];
};
export type QueryPositionssArgs = {
after?: InputMaybe<Scalars['String']['input']>;
before?: InputMaybe<Scalars['String']['input']>;
@ -54,12 +52,10 @@ export type QueryPositionssArgs = {
where?: InputMaybe<PositionsFilter>;
};
export type QueryStatsArgs = {
id: Scalars['String']['input'];
};
export type QueryStatssArgs = {
after?: InputMaybe<Scalars['String']['input']>;
before?: InputMaybe<Scalars['String']['input']>;

View file

@ -11,15 +11,15 @@ import StakeForgeOutput from '../../onchain/out/Stake.sol/Stake.json' assert { t
/**
* Kraiken ERC20 token contract ABI
*/
export const KraikenAbi = KraikenForgeOutput.abi;
export const KRAIKEN_ABI = KraikenForgeOutput.abi;
/**
* Stake (Harberger tax staking) contract ABI
*/
export const StakeAbi = StakeForgeOutput.abi;
export const STAKE_ABI = StakeForgeOutput.abi;
// Re-export for convenience
export const ABIS = {
Kraiken: KraikenAbi,
Stake: StakeAbi,
Kraiken: KRAIKEN_ABI,
Stake: STAKE_ABI,
} as const;

View file

@ -1,4 +1,4 @@
export * from "./staking.js";
export * from "./snatch.js";
export * from "./ids.js";
export * from "./taxRates.js";
export * from './staking.js';
export * from './snatch.js';
export * from './ids.js';
export * from './taxRates.js';

View file

@ -1,20 +1,18 @@
import { bytesToUint256LittleEndian } from "./subgraph.js";
import { bytesToUint256LittleEndian } from './subgraph.js';
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}`;
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");
throw new Error('Unsupported position id type');
}
export function decodePositionId(
id: string | Uint8Array | number | bigint
): bigint {
export function decodePositionId(id: string | Uint8Array | number | bigint): bigint {
return toBigIntId(id);
}

View file

@ -1,20 +1,11 @@
export {
bytesToUint256LittleEndian,
uint256ToBytesLittleEndian,
} from "./subgraph.js";
export { bytesToUint256LittleEndian, uint256ToBytesLittleEndian } from './subgraph.js';
// Backward compatible aliases
export {
bytesToUint256LittleEndian as bytesToUint256,
uint256ToBytesLittleEndian as uint256ToBytes,
} from "./subgraph.js";
export { bytesToUint256LittleEndian as bytesToUint256, uint256ToBytesLittleEndian as uint256ToBytes } from './subgraph.js';
export { TAX_RATE_OPTIONS, type TaxRateOption } from "./taxRates.js";
export { TAX_RATE_OPTIONS, type TaxRateOption } from './taxRates.js';
export {
calculateSnatchShortfall,
isPositionDelinquent,
} from "./staking.js";
export { calculateSnatchShortfall, isPositionDelinquent } from './staking.js';
export {
minimumTaxRate,
@ -23,8 +14,11 @@ export {
type SnatchablePosition,
type SnatchSelectionOptions,
type SnatchSelectionResult,
} from "./snatch.js";
} from './snatch.js';
export { decodePositionId } from "./ids.js";
export { decodePositionId } from './ids.js';
export { KraikenAbi, StakeAbi, ABIS } from "./abis.js";
export { KRAIKEN_ABI, STAKE_ABI, ABIS } from './abis.js';
// Backward compatible aliases
export { KRAIKEN_ABI as KraikenAbi, STAKE_ABI as StakeAbi } from './abis.js';

View file

@ -1,5 +1,5 @@
import type { Positions } from "./__generated__/graphql.js";
import { toBigIntId } from "./ids.js";
import type { Positions } from './__generated__/graphql.js';
import { toBigIntId } from './ids.js';
export interface SnatchablePosition {
id: bigint;
@ -27,30 +27,16 @@ export interface SnatchSelectionResult {
const SHARE_SCALE = 1_000_000n;
function normaliseAddress(value?: string | null): string {
return (value ?? "").toLowerCase();
return (value ?? '').toLowerCase();
}
export function minimumTaxRate<T extends { taxRate: number }>(
positions: T[],
fallback: number = 0
): number {
export function minimumTaxRate<T extends { taxRate: number }>(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
);
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;
export function selectSnatchPositions(candidates: SnatchablePosition[], options: SnatchSelectionOptions): SnatchSelectionResult {
const { shortfallShares, maxTaxRate, includeOwned = false, recipientAddress = null } = options;
if (shortfallShares <= 0n) {
return {
@ -62,7 +48,7 @@ export function selectSnatchPositions(
const recipientNormalised = normaliseAddress(recipientAddress);
const filtered = candidates.filter((candidate) => {
const filtered = candidates.filter(candidate => {
if (candidate.taxRate >= maxTaxRate) {
return false;
}
@ -100,17 +86,14 @@ export function selectSnatchPositions(
covered = shortfallShares - remaining;
}
if (
maxSelectedTaxRate === undefined ||
candidate.taxRate > maxSelectedTaxRate
) {
if (maxSelectedTaxRate === undefined || candidate.taxRate > maxSelectedTaxRate) {
maxSelectedTaxRate = candidate.taxRate;
if (typeof candidate.taxRateIndex === "number") {
if (typeof candidate.taxRateIndex === 'number') {
maxSelectedTaxRateIndex = candidate.taxRateIndex;
}
} else if (
candidate.taxRate === maxSelectedTaxRate &&
typeof candidate.taxRateIndex === "number" &&
typeof candidate.taxRateIndex === 'number' &&
candidate.taxRateIndex > (maxSelectedTaxRateIndex ?? -1)
) {
maxSelectedTaxRateIndex = candidate.taxRateIndex;
@ -133,14 +116,12 @@ export function getSnatchList(
stakeTotalSupply: bigint
): bigint[] {
if (stakeTotalSupply <= 0n) {
throw new Error("stakeTotalSupply must be greater than zero");
throw new Error('stakeTotalSupply must be greater than zero');
}
const candidates: SnatchablePosition[] = positions.map((position) => {
const candidates: SnatchablePosition[] = positions.map(position => {
const shareRatio = Number(position.share);
const scaledShares = BigInt(
Math.round(shareRatio * Number(SHARE_SCALE))
);
const scaledShares = BigInt(Math.round(shareRatio * Number(SHARE_SCALE)));
return {
id: toBigIntId(position.id),
@ -157,8 +138,8 @@ export function getSnatchList(
});
if (result.remainingShortfall > 0n) {
throw new Error("Not enough capacity");
throw new Error('Not enough capacity');
}
return result.selected.map((candidate) => candidate.id);
return result.selected.map(candidate => candidate.id);
}

View file

@ -8,7 +8,7 @@ export function calculateSnatchShortfall(
capDenominator: bigint = 10n
): bigint {
if (capDenominator === 0n) {
throw new Error("capDenominator must be greater than zero");
throw new Error('capDenominator must be greater than zero');
}
const cap = (stakeTotalSupply * capNumerator) / capDenominator;

View file

@ -1,32 +1,31 @@
export function bytesToUint256LittleEndian(bytes: Uint8Array): bigint {
// console.log(hexString);
// const cleanHexString = hexString.startsWith('0x') ? hexString.substring(2) : hexString;
// const bytes = new Uint8Array(Math.ceil(cleanHexString.length / 2));
// for (let i = 0, j = 0; i < cleanHexString.length; i += 2, j++) {
// bytes[j] = parseInt(cleanHexString.slice(i, i + 2), 16);
// }
let value: bigint = 0n;
// console.log(hexString);
// const cleanHexString = hexString.startsWith('0x') ? hexString.substring(2) : hexString;
// const bytes = new Uint8Array(Math.ceil(cleanHexString.length / 2));
// for (let i = 0, j = 0; i < cleanHexString.length; i += 2, j++) {
// bytes[j] = parseInt(cleanHexString.slice(i, i + 2), 16);
// }
let value: bigint = 0n;
for (let i = bytes.length - 1; i >= 0; i--) {
value = (value << 8n) | BigInt(bytes[i]);
}
for (let i = bytes.length - 1; i >= 0; i--) {
value = (value << 8n) | BigInt(bytes[i]);
}
return value;
return value;
}
export function uint256ToBytesLittleEndian(value: bigint): Uint8Array {
const bytes = new Uint8Array(4);
for (let i = 0; i < 4; i++) {
bytes[i] = Number((value >> (8n * BigInt(i))) & 0xFFn);
}
return bytes;
// let hexString = '0x';
const bytes = new Uint8Array(4);
for (let i = 0; i < 4; i++) {
bytes[i] = Number((value >> (8n * BigInt(i))) & 0xffn);
}
return bytes;
// let hexString = '0x';
// for (let i = 0; i < bytes.length; i++) {
// // Convert each byte to a hexadecimal string and pad with zero if needed
// hexString += bytes[i].toString(16).padStart(2, '0');
// }
// for (let i = 0; i < bytes.length; i++) {
// // Convert each byte to a hexadecimal string and pad with zero if needed
// hexString += bytes[i].toString(16).padStart(2, '0');
// }
// return hexString;
}
// return hexString;
}

View file

@ -1,11 +1,9 @@
import { describe, expect, test } from "@jest/globals";
import { bytesToUint256LittleEndian, uint256ToBytesLittleEndian } from "../subgraph";
import { describe, expect, test } from '@jest/globals';
import { bytesToUint256LittleEndian, uint256ToBytesLittleEndian } from '../subgraph';
import { Position, PositionStatus } from '../__generated__/graphql';
describe('BigInt Conversion Functions', () => {
test('converts uint256 to bytes and back (little endian)', async () => {
const mockPos: Position = {
__typename: 'Position',
id: uint256ToBytesLittleEndian(3n),
@ -23,8 +21,7 @@ describe('BigInt Conversion Functions', () => {
}
expect(hexString).toEqual('0x03000000');
// return hexString;
// return hexString;
expect(bytesToUint256LittleEndian(mockPos.id)).toEqual(3n);
});
});

View file

@ -1,9 +1,9 @@
import { describe, expect, test } from "@jest/globals";
import { decodePositionId } from "../ids";
import { uint256ToBytesLittleEndian } from "../subgraph";
import { describe, expect, test } from '@jest/globals';
import { decodePositionId } from '../ids';
import { uint256ToBytesLittleEndian } from '../subgraph';
describe("ids", () => {
test("decodePositionId works across representations", () => {
describe('ids', () => {
test('decodePositionId works across representations', () => {
const id = 12345n;
const hex = `0x${id.toString(16)}`;
const bytes = uint256ToBytesLittleEndian(id);

View file

@ -1,22 +1,17 @@
import { describe, expect, test } from "@jest/globals";
import {
getSnatchList,
minimumTaxRate,
selectSnatchPositions,
type SnatchablePosition,
} from "../snatch";
import { uint256ToBytesLittleEndian } from "../subgraph";
import type { Position } from "../__generated__/graphql";
import { PositionStatus } from "../__generated__/graphql";
import { describe, expect, test } from '@jest/globals';
import { getSnatchList, minimumTaxRate, selectSnatchPositions, type SnatchablePosition } from '../snatch';
import { uint256ToBytesLittleEndian } from '../subgraph';
import type { Position } from '../__generated__/graphql';
import { PositionStatus } from '../__generated__/graphql';
describe("snatch", () => {
test("minimumTaxRate finds the lowest tax", () => {
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);
expect(minimumTaxRate([], 0.42)).toBe(0.42);
});
test("selectSnatchPositions chooses cheapest positions first", () => {
test('selectSnatchPositions chooses cheapest positions first', () => {
const candidates: SnatchablePosition[] = [
{ id: 1n, stakeShares: 30n, taxRate: 0.05, taxRateIndex: 5 },
{ id: 2n, stakeShares: 40n, taxRate: 0.03, taxRateIndex: 3 },
@ -28,13 +23,13 @@ describe("snatch", () => {
maxTaxRate: 0.08,
});
expect(result.selected.map((item) => item.id)).toEqual([2n, 1n]);
expect(result.selected.map(item => item.id)).toEqual([2n, 1n]);
expect(result.remainingShortfall).toBe(0n);
expect(result.coveredShares).toBe(60n);
expect(result.maxSelectedTaxRateIndex).toBe(5);
});
test("selectSnatchPositions keeps track of remaining shortfall", () => {
test('selectSnatchPositions keeps track of remaining shortfall', () => {
const candidates: SnatchablePosition[] = [
{ id: 1n, stakeShares: 10n, taxRate: 0.01 },
{ id: 2n, stakeShares: 10n, taxRate: 0.02 },
@ -48,14 +43,14 @@ describe("snatch", () => {
expect(result.remainingShortfall).toBe(30n);
});
test("getSnatchList converts subgraph positions", () => {
test('getSnatchList converts subgraph positions', () => {
const stakeTotalSupply = 1_000_000n * 10n ** 18n;
const positions: Position[] = [
{
__typename: "Position",
__typename: 'Position',
id: uint256ToBytesLittleEndian(1n),
owner: "0xowner1",
owner: '0xowner1',
share: 0.0001,
creationTime: 0,
lastTaxTime: 0,
@ -63,9 +58,9 @@ describe("snatch", () => {
status: PositionStatus.Active,
},
{
__typename: "Position",
__typename: 'Position',
id: uint256ToBytesLittleEndian(2n),
owner: "0xowner2",
owner: '0xowner2',
share: 0.0002,
creationTime: 0,
lastTaxTime: 0,

View file

@ -1,8 +1,8 @@
import { describe, expect, test } from "@jest/globals";
import { calculateSnatchShortfall, isPositionDelinquent } from "../staking";
import { describe, expect, test } from '@jest/globals';
import { calculateSnatchShortfall, isPositionDelinquent } from '../staking';
describe("staking", () => {
test("calculateSnatchShortfall returns zero when within cap", () => {
describe('staking', () => {
test('calculateSnatchShortfall returns zero when within cap', () => {
const outstanding = 100n;
const desired = 50n;
const total = 1000n;
@ -11,7 +11,7 @@ describe("staking", () => {
expect(result).toBe(0n);
});
test("calculateSnatchShortfall returns positive remainder when exceeding cap", () => {
test('calculateSnatchShortfall returns positive remainder when exceeding cap', () => {
const outstanding = 200n;
const desired = 200n;
const total = 1000n;
@ -20,7 +20,7 @@ describe("staking", () => {
expect(result).toBe(200n);
});
test("isPositionDelinquent respects tax rate windows", () => {
test('isPositionDelinquent respects tax rate windows', () => {
const now = 1_000_000;
const taxRate = 0.5; // 50%
const windowSeconds = (365 * 24 * 60 * 60) / taxRate;

View file

@ -1,11 +1,9 @@
import { describe, expect, test } from "@jest/globals";
import { TAX_RATE_OPTIONS } from "../taxRates";
import { describe, expect, test } from '@jest/globals';
import { TAX_RATE_OPTIONS } from '../taxRates';
describe("taxRates", () => {
test("tax rate options exported for consumers", () => {
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 })
);
expect(TAX_RATE_OPTIONS[0]).toEqual(expect.objectContaining({ index: 0, year: 1, decimal: 0.01 }));
});
});

File diff suppressed because it is too large Load diff