Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/types.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Type Check

on:
push:
branches: [main]
pull_request:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
tests:
runs-on: ubuntu-latest

strategy:
matrix:
node-version: [22.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/

steps:
- uses: actions/checkout@v7
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Check types
run: npm run check-types
4 changes: 2 additions & 2 deletions .husky/pre-push
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
#!/usr/bin/env sh

echo "Running pre-push: lint + format checks"
echo "Running pre-push: lint + format + type checks"

if ! command -v npm >/dev/null 2>&1; then
echo "npm not found in PATH. Install Node.js/npm before pushing." >&2
exit 1
fi

if ! npm run check-lint && npm run check-format; then
if ! npm run check-lint || ! npm run check-format || ! npm run check-types; then
echo "Pre-push checks failed. Push aborted." >&2
exit 1
fi
Expand Down
9 changes: 5 additions & 4 deletions AGENTS-CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ Local installs configure Husky hooks automatically.

## Common tasks

CI check: `npm run prtasks` (lint, format, api:update, tests).
Repo-wide lint checks: `npm run check-lint` and `npm run check-format`.
CI check: `npm run prtasks` (lint, format, types, api:update, tests).
Repo-wide checks: `npm run check-lint`, `npm run check-format` and `npm run check-types`.
Integration tests: `npm run test-integration` (requires local mint, see below).
Consumer smoke tests: `npm run test:consumer`.
Other scripts: see `package.json`.
Expand Down Expand Up @@ -101,6 +101,7 @@ DEV=1 make nutshell-stable-down

- Lint config lives in `eslint.config.js` (flat config).
- Tests are linted too, with a relaxed override block (fixture `any`s allowed, but promise correctness and vitest hygiene — no `.only`, no assertion-free tests — are enforced). `test/tsconfig.json` exists so typed rules cover test files.
- `npm run check-types` type checks the test tree (and, via its imports, `src/`). Vitest strips types without checking them, so this is the only thing that catches fixtures drifting from the library types.
- `any` is not allowed (prefer explicit types, generics, or `unknown` with narrowing).
- Type-only imports/exports are required (`@typescript-eslint/consistent-type-imports`).
- No Node-only modules in library code (`import/no-nodejs-modules`).
Expand Down Expand Up @@ -134,11 +135,11 @@ Hooks are installed by Husky:

## Amount model (v4 breaking change)

- `Proof.amount` is `bigint`, not `number`. This changed in v4.
- `Proof.amount` is an `Amount`, not a `number` or a raw `bigint`.
- `AmountLike` (`number | bigint | string | Amount`) is the flexible input type for consumers.
- `Amount` (`src/model/Amount.ts`) is the normalization and arithmetic layer. Use `Amount.from(x)` to convert any `AmountLike`, `.toBigInt()` to extract the raw value.
- Mint response DTOs are normalized to `Amount` before reaching consumers — API response amount fields return `Amount` objects, not raw numbers.
- When constructing `Proof` objects, always normalize: `amount: Amount.from(x).toBigInt()`.
- When constructing `Proof` objects, always normalize: `amount: Amount.from(x)`. Use `ProofLike` (`amount: AmountLike`) to model un-normalized proofs from external storage.
- Avoid `number` in canonical domain models and avoid `bigint | number` in stored/core types.

## Branching and releases
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,13 @@
"check-lint": "eslint '**/*.{js,ts}'",
"format": "prettier --write .",
"check-format": "prettier --check .",
"check-types": "tsc -p test/tsconfig.json",
"typedoc": "typedoc",
"api:check": "npm run compile && api-extractor run",
"api:update": "npm run compile && api-extractor run --local",
"setup-hooks": "chmod +x scripts/install-git-hooks.sh && ./scripts/install-git-hooks.sh",
"uninstall-hooks": "git config --local --unset core.hooksPath && [ \"${REMOVE_GITHOOKS:-0}\" = \"1\" ] && rm -rf .githooks || echo 'To remove the installed hooks set REMOVE_GITHOOKS=1'",
"prtasks": "npm run lint && npm run format && npm run api:update && npm run test && git status",
"prtasks": "npm run lint && npm run format && npm run check-types && npm run api:update && npm run test && git status",
"prepare": "husky && [ \"$CI\" != \"true\" ] && npm run setup-git || true",
"setup-git": "git config commit.template .gitmessage",
"migrate-hooks": "REMOVE_GITHOOKS=1 npm run uninstall-hooks && npm install",
Expand Down
2 changes: 1 addition & 1 deletion test/bytes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ describe('Bytes utility class', () => {
test('large arrays produce valid base64 on the btoa fallback path (no Buffer)', () => {
// Force the chunked btoa path that browsers use; harmless where Buffer
// is already absent.
const g = globalThis as typeof globalThis & { Buffer?: unknown };
const g = globalThis as unknown as { Buffer?: unknown };
const originalBuffer = g.Buffer;
try {
g.Buffer = undefined;
Expand Down
7 changes: 2 additions & 5 deletions test/mint/Mint.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
RateLimitError,
Amount,
} from '../../src';
import type { AuthProvider, Logger, RequestFn } from '../../src';
import type { AuthProvider, Logger, MintQuoteBaseResponse, RequestFn } from '../../src';
import { MINTINFORESP } from '../consts';

type ReqArgs = {
Expand Down Expand Up @@ -571,10 +571,7 @@ describe('Mint normalization', () => {
]) as RequestFn;
const mint = new Mint(mintUrl, { customRequest: requestSpy });

type CustomQuote = {
quote: string;
request: string;
unit: string;
type CustomQuote = MintQuoteBaseResponse & {
amount: Amount;
reference: string;
};
Expand Down
3 changes: 2 additions & 1 deletion test/model/Errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ describe('CTSError', () => {
test('cause is writable and reassignable', () => {
const err = new CTSError('boom', { cause: new Error('first') });
const replacement = new Error('second');
err.cause = replacement;
// `cause` is declared readonly, but the descriptor is writable at runtime.
(err as { cause?: unknown }).cause = replacement;
expect(err.cause).toBe(replacement);
});

Expand Down
9 changes: 8 additions & 1 deletion test/model/MintInfo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -860,7 +860,14 @@ describe('MintInfo snapshot accessors', () => {

describe('MintInfo list caps', () => {
function spyLogger() {
return { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn(), trace: vi.fn() };
return {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
trace: vi.fn(),
log: vi.fn(),
};
}

it('truncates an oversized NUT-04 method list and warns', () => {
Expand Down
7 changes: 1 addition & 6 deletions test/model/SigAll.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,6 @@ function makeSwapPreview() {
sendOutputs: [dummyOutput],
amount: Amount.from(32),
fees: Amount.from(0),
keysetIdts: [],
method: 'swap',
keysetId: 'dummy-keyset-id',
} as SwapPreview;
}
Expand All @@ -64,12 +62,9 @@ function makeMeltPreview() {
state: MeltQuoteState.PENDING,
expiry: Date.now() + 10000,
},
amount: Amount.from(32),
fees: 0,
keysetIdts: [],
method: 'melt',
keysetId: 'dummy-keyset-id',
} as MeltPreview;
} as MeltPreview<{ quote: string }>;
}

// Helper: encode an arbitrary object as a sigallA-prefixed string,
Expand Down
8 changes: 4 additions & 4 deletions test/transport/request.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ describe('requests', { timeout: 7500 }, () => {
const thrown = await request({ endpoint }).catch((e) => e);
expect(thrown).toBeInstanceOf(HttpResponseError);
expect(thrown).toMatchObject({ message: 'bad response', status: 200 });
expect(thrown.cause).toMatchObject({ message: 'Empty response body' });
expect((thrown as HttpResponseError).cause).toMatchObject({ message: 'Empty response body' });
});

test('maps malformed success JSON to bad response and logs parsing failure', async () => {
Expand All @@ -304,7 +304,7 @@ describe('requests', { timeout: 7500 }, () => {
const thrown = await request({ endpoint }).catch((e) => e);
expect(thrown).toBeInstanceOf(HttpResponseError);
expect(thrown).toMatchObject({ message: 'bad response' });
expect(thrown.cause).toBeInstanceOf(Error);
expect((thrown as HttpResponseError).cause).toBeInstanceOf(Error);
expect(logger.error).toHaveBeenCalledWith(
'Failed to parse HTTP response',
expect.objectContaining({ err: expect.any(Error) }),
Expand All @@ -326,7 +326,7 @@ describe('requests', { timeout: 7500 }, () => {
const thrown = await request({ endpoint }).catch((e) => e);
expect(thrown).toBeInstanceOf(NetworkError);
expect(thrown).toMatchObject({ message: 'aborted by runtime' });
expect(thrown.cause).toBe(abortError);
expect((thrown as NetworkError).cause).toBe(abortError);
} finally {
fetchMock.mockRestore();
}
Expand All @@ -348,7 +348,7 @@ describe('requests', { timeout: 7500 }, () => {
const thrown = await request({ endpoint }).catch((e) => e);
expect(thrown).toBeInstanceOf(HttpResponseError);
expect(thrown).toMatchObject({ message: 'bad response', status: 503 });
expect(thrown.cause).toBe(bodyReadError);
expect((thrown as HttpResponseError).cause).toBe(bodyReadError);
} finally {
fetchMock.mockRestore();
}
Expand Down
17 changes: 15 additions & 2 deletions test/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
{
// Gives eslint's typed rules (projectService) a project covering test/ —
// the root tsconfig only includes src/. Not used by builds or vitest.
// the root tsconfig only includes src/. Also drives `npm run check-types`,
// which type checks test/ and, via its imports, src/. Not used by builds
// or vitest (vitest strips types without checking them).
"extends": "../tsconfig.json",
"include": ["**/*"]
"compilerOptions": {
// Tests import from ../src, so the project root sits above test/.
"rootDir": "..",
"noEmit": true,
"declaration": false,
"emitDeclarationOnly": false
},
"include": ["**/*"],
// Consumer smoke harnesses import the built package by its published name, so
// they only resolve after a build. They have their own workflow (consumers.yml)
// and eslint skips them for the same reason.
"exclude": ["consumer/**"]
}
8 changes: 8 additions & 0 deletions test/wallet/WalletCounters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ class MandatoryOnlySource implements CounterSource {
return Promise.resolve({ start: cur, count: n });
}

reserveAt(keysetId: string, start: number, count: number): Promise<CounterRange> {
const cur = this.next.get(keysetId) ?? 0;
if (start < 0 || count < 0) throw new Error('reserveAt called with a negative start or count');
if (start < cur) throw new Error(`Counter ${start} for keyset ${keysetId} was already issued`);
this.next.set(keysetId, start + count);
return Promise.resolve({ start, count });
}

advanceToAtLeast(keysetId: string, minNext: number): Promise<void> {
const cur = this.next.get(keysetId) ?? 0;
if (minNext > cur) this.next.set(keysetId, minNext);
Expand Down
39 changes: 34 additions & 5 deletions test/wallet/WalletOps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,11 +160,15 @@ class MockWallet {
checkMintQuoteBolt11: Mock<CheckMintQuoteBolt11Fn> = vi.fn<CheckMintQuoteBolt11Fn>(
async (id) => ({
quote: id,
method: 'bolt11',
state: 'UNPAID',
expiry: 0,
request: '',
amount: Amount.from(0),
unit: '',
amount_paid: Amount.from(0),
amount_issued: Amount.from(0),
updated_at: null,
}),
);
validateMintQuote: Mock<ValidateMintQuoteFn> = vi.fn<ValidateMintQuoteFn>();
Expand Down Expand Up @@ -207,6 +211,7 @@ const quote = 'q123';

const melt11: MeltQuoteBolt11Response = {
quote: 'mq11',
method: 'bolt11',
amount: Amount.from(5),
fee_reserve: Amount.from(1),
state: 'UNPAID',
Expand All @@ -218,6 +223,7 @@ const melt11: MeltQuoteBolt11Response = {

const melt12: MeltQuoteBolt12Response = {
quote: 'mq12',
method: 'bolt12',
amount: Amount.from(7),
fee_reserve: Amount.from(2),
state: 'UNPAID',
Expand All @@ -229,17 +235,20 @@ const melt12: MeltQuoteBolt12Response = {

const mint12: MintQuoteBolt12Response = {
quote: 'mq12',
method: 'bolt12',
request: 'lno1...',
amount: Amount.from(7),
unit: 'sat',
expiry: 0,
pubkey: '0200000',
amount_paid: Amount.from(0),
amount_issued: Amount.from(0),
updated_at: null,
};

const meltOnchainSingle: MeltQuoteOnchainResponse = {
quote: 'mq-onchain-melt-1',
method: 'onchain',
amount: Amount.from(10),
state: 'UNPAID',
expiry: 0,
Expand All @@ -252,6 +261,7 @@ const meltOnchainSingle: MeltQuoteOnchainResponse = {

const meltOnchainMulti: MeltQuoteOnchainResponse = {
quote: 'mq-onchain-melt-2',
method: 'onchain',
amount: Amount.from(10),
state: 'UNPAID',
expiry: 0,
Expand All @@ -267,13 +277,14 @@ const meltOnchainMulti: MeltQuoteOnchainResponse = {

const mintOnchain: MintQuoteOnchainResponse = {
quote: 'mq-onchain',
method: 'onchain',
request: 'bc1qdeposit',
amount: Amount.from(8),
unit: 'sat',
expiry: null,
pubkey: '0200000',
amount_paid: Amount.from(0),
amount_issued: Amount.from(0),
updated_at: null,
};

describe('WalletOps builders', () => {
Expand Down Expand Up @@ -369,7 +380,7 @@ describe('WalletOps builders', () => {
const locked = new PaymentRequest({
amount: 100,
unit: 'sat',
nut10: { kind: 'P2PK', data: '02'.padEnd(66, 'a') },
nut10: { kind: 'P2PK', data: '02'.padEnd(66, 'a'), tags: [] },
});
await ops.sendToRequest(locked, proofs).run();
const outputConfig = wallet.send.mock.calls[0][3];
Expand All @@ -378,7 +389,7 @@ describe('WalletOps builders', () => {
const exotic = new PaymentRequest({
amount: 100,
unit: 'sat',
nut10: { kind: 'FROST', data: 'xyz' },
nut10: { kind: 'FROST', data: 'xyz', tags: [] },
});
expect(() => ops.sendToRequest(exotic, proofs)).toThrow(/nut10 lock/);
});
Expand Down Expand Up @@ -813,7 +824,16 @@ describe('WalletOps builders', () => {
payload: { quote, outputs: [] },
outputData: [],
keysetId: '123',
quote: { quote, request: '', unit: '' },
quote: {
quote,
method: 'bolt11',
request: '',
unit: '',
expiry: null,
amount_paid: Amount.from(0),
amount_issued: Amount.from(0),
updated_at: null,
},
};
wallet.prepareMint.mockResolvedValueOnce(preview);

Expand Down Expand Up @@ -873,7 +893,16 @@ describe('WalletOps builders', () => {
payload: { quote: mint12.quote, outputs: [] },
outputData: [],
keysetId: '123',
quote: { quote: mint12.quote, request: '', unit: '' },
quote: {
quote: mint12.quote,
method: 'bolt12',
request: '',
unit: '',
expiry: null,
amount_paid: Amount.from(0),
amount_issued: Amount.from(0),
updated_at: null,
},
};
wallet.prepareMint.mockResolvedValueOnce(preview);

Expand Down
4 changes: 2 additions & 2 deletions test/wallet/_setup.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { WebSocket } from 'mock-socket';
import { HttpResponse, http } from 'msw';
import { setupServer, type SetupServerApi } from 'msw/node';
import { setupServer, type SetupServer } from 'msw/node';
import { beforeAll, beforeEach, afterAll, afterEach } from 'vitest';

import { Mint, ConsoleLogger, injectWebSocketImpl } from '../../src';
Expand All @@ -27,7 +27,7 @@ export const token3sat =
'cashuBo2FtdWh0dHA6Ly9sb2NhbGhvc3Q6MzMzOGF1Y3NhdGF0gaJhaUgAvQM1Wd4n0GFwgqNhYQFhc3hAZTdjMWI3NmQxYjMxZTJiY2EyYjIyOWQxNjBiZGY2MDQ2ZjMzYmM0NTcwMjIyMzA0YjY1MTEwZDkyNmY3YWY4OWFjWCEDic2fT5iOOAp5idTUiKfJHFJ3-5MEfnoswe2OM5a4VP-jYWECYXN4QGRlNTVjMTVmYWVmZGVkN2Y5Yzk5OWMzZDRjNjJmODFiMGM2ZmUyMWE3NTJmZGVmZjZiMDg0Y2YyZGYyZjVjZjNhY1ghAt5AxZ2QODuIU8zzpLIIZKyDunWPzj2VnbuJNhAC6M5H';
export const logger = new ConsoleLogger('debug');

export function setupDefaultHandlers(server: SetupServerApi) {
export function setupDefaultHandlers(server: SetupServer) {
server.use(
http.get(mintUrl + '/v1/info', () => {
return HttpResponse.json(mintInfoResp);
Expand Down
Loading
Loading