Skip to content
Closed
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
6 changes: 6 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 8 additions & 1 deletion packages/wallet-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@
"./*": "./src/*"
},
"scripts": {
"typecheck": "tsc"
"typecheck": "tsc",
"test": "bun test"
},
"dependencies": {
"@agicash/opensecret": "catalog:",
"@cashu/cashu-ts": "3.6.1",
"@supabase/supabase-js": "2.95.2",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the idea is to put all libs that are shared by multiple packages/apps in our monorepo to the catalog (@pmilic021 correct me if wrong)

"jwt-decode": "4.0.0"
},
"devDependencies": {
"typescript": "catalog:"
Expand Down
197 changes: 197 additions & 0 deletions packages/wallet-sdk/src/classify.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import {
HttpResponseError,
MintOperationError,
NetworkError,
} from '@cashu/cashu-ts';
import { describe, expect, test } from 'bun:test';
import { classify } from './classify';
import {
ConcurrencyError,
DomainError,
NotFoundError,
NotImplementedError,
} from './errors';
import { CashuErrorCodes } from './internal/cashu-error-codes';

describe('classify', () => {
describe('transient', () => {
test('ConcurrencyError (SDK optimistic-lock signal)', () => {
expect(classify(new ConcurrencyError('stale', 'CONCURRENCY_ERROR'))).toBe(
'transient',
);
});

test('NetworkError (connectivity/transport failure)', () => {
expect(classify(new NetworkError('connection refused'))).toBe(
'transient',
);
});

test('bare HttpResponseError such as 429 / 5xx', () => {
expect(classify(new HttpResponseError('rate limited', 429))).toBe(
'transient',
);
expect(classify(new HttpResponseError('bad gateway', 502))).toBe(
'transient',
);
});

test('in-flight-elsewhere mint codes (OUTPUTS/PROOFS pending, quote pending)', () => {
expect(
classify(
new MintOperationError(
CashuErrorCodes.OUTPUTS_ARE_PENDING, // 11004
'outputs are pending',
),
),
).toBe('transient');
expect(
classify(
new MintOperationError(
CashuErrorCodes.PROOFS_ARE_PENDING, // 11002
'proofs are pending',
),
),
).toBe('transient');
expect(
classify(
new MintOperationError(
CashuErrorCodes.QUOTE_PENDING, // 20005
'quote is pending',
),
),
).toBe('transient');
});
});

describe('already-resolved', () => {
test('token already spent', () => {
expect(
classify(
new MintOperationError(
CashuErrorCodes.TOKEN_ALREADY_SPENT, // 11001
'Token already spent',
),
),
).toBe('already-resolved');
});

test('output already signed', () => {
expect(
classify(
new MintOperationError(
CashuErrorCodes.OUTPUT_ALREADY_SIGNED, // 11003
'Output already signed',
),
),
).toBe('already-resolved');
});

test('quote already issued', () => {
expect(
classify(
new MintOperationError(
CashuErrorCodes.QUOTE_ALREADY_ISSUED, // 20002
'Tokens already issued for quote',
),
),
).toBe('already-resolved');
});

test('invoice already paid', () => {
expect(
classify(
new MintOperationError(
CashuErrorCodes.INVOICE_ALREADY_PAID, // 20006
'Invoice already paid',
),
),
).toBe('already-resolved');
});
});

describe('permanent', () => {
test('DomainError (definitive, user-facing failure)', () => {
expect(classify(new DomainError('insufficient balance', 'DOMAIN'))).toBe(
'permanent',
);
});

test('NotFoundError (requested entity missing)', () => {
expect(
classify(new NotFoundError('account not found', 'NOT_FOUND')),
).toBe('permanent');
});

test('other mint rejections (deterministic, never succeed on retry)', () => {
// transaction not balanced
expect(
classify(
new MintOperationError(
CashuErrorCodes.TRANSACTION_NOT_BALANCED, // 11005
'Transaction is not balanced',
),
),
).toBe('permanent');
// amount out of limits
expect(
classify(
new MintOperationError(
CashuErrorCodes.AMOUNT_OUT_OF_LIMITS, // 11006
'Amount outside of limit range',
),
),
).toBe('permanent');
// keyset inactive
expect(
classify(
new MintOperationError(
CashuErrorCodes.KEYSET_INACTIVE, // 12002
'Keyset is inactive',
),
),
).toBe('permanent');
// quote not paid
expect(
classify(
new MintOperationError(
CashuErrorCodes.QUOTE_NOT_PAID, // 20001
'Quote is not paid',
),
),
).toBe('permanent');
// blind auth required
expect(
classify(
new MintOperationError(
CashuErrorCodes.BLIND_AUTH_REQUIRED, // 31001
'Endpoint requires blind auth',
),
),
).toBe('permanent');
});
});

describe('unhandled', () => {
test('a plain Error is not recognised', () => {
expect(classify(new Error('boom'))).toBe('unhandled');
});

test('a TypeError is not recognised', () => {
expect(classify(new TypeError('x is not a function'))).toBe('unhandled');
});

test('non-Error thrown values (string, null, undefined, object)', () => {
expect(classify('a string')).toBe('unhandled');
expect(classify(null)).toBe('unhandled');
expect(classify(undefined)).toBe('unhandled');
expect(classify({ code: 11004 })).toBe('unhandled');
});

test('NotImplementedError is an SdkError but not Domain/NotFound -> unhandled', () => {
// NotImplementedError extends SdkError but neither DomainError nor
// NotFoundError, so classify falls through to 'unhandled'.
expect(classify(new NotImplementedError('cashu.send'))).toBe('unhandled');
});
});
});
119 changes: 119 additions & 0 deletions packages/wallet-sdk/src/classify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* Error classifier — §12 of the contract (the locked 4-bucket seam).
*
* `classify(err)` is a PURE function returning a bare 4-bucket string union
* (gudnuf's no-repair-hints ruling: NO repair hints, services handle the
* specifics). It is consumed by BOTH the SDK `executeQuote` orchestrator AND
* (later) the web hooks. The orchestrator maps the verdict onto its error model:
*
* | verdict | meaning | orchestrator action |
* | ------------------ | ----------------------------------------- | ---------------------------- |
* | `transient` | stale / in-flight / connectivity | retry / recover (→ `ConcurrencyError`) |
* | `permanent` | the mint/peer rejected it; never succeeds | surface (→ `DomainError`) |
* | `already-resolved` | the operation already happened | no-op (reconcile via restore) |
* | `unhandled` | not recognised | propagate |
*
* GROUNDING. The mapping ports master's hook-resident branching, which gates retry
* purely on `error instanceof MintOperationError` (a mint-emitted protocol error →
* no retry, fail the quote) vs everything else (→ retry up to 3×). See
* `app/features/send/cashu-send-quote-hooks.ts` (~L331) +
* `app/features/receive/cashu-receive-quote-hooks.ts` (~L704). This function refines
* that binary into the 4 buckets using the NUT `code` carried on `MintOperationError`:
* - in-flight-elsewhere codes (OUTPUTS/PROOFS pending — #1115 mapped 11004/11002
* → transient) become `transient` (a parallel op holds them; retry/recover);
* - already-happened codes (already-spent / already-issued / already-paid /
* output-already-signed) become `already-resolved` (recovery = restore, no-op);
* - every other mint rejection stays `permanent`.
* cashu-ts `NetworkError` and bare `HttpResponseError` (e.g. 429 / 5xx — the master
* receive hook treats 429 as retryable) are connectivity/transport → `transient`.
*
* @module
*/
import {
HttpResponseError,
MintOperationError,
NetworkError,
} from '@cashu/cashu-ts';
import { ConcurrencyError, DomainError, NotFoundError } from './errors';
import { CashuErrorCodes } from './internal/cashu-error-codes';

/**
* The four buckets every error collapses into. Bare strings — no repair hints
* (a verdict, not an instruction). The caller/orchestrator owns the response.
*/
export type ErrorClass =
| 'transient'
| 'permanent'
| 'already-resolved'
| 'unhandled';

/**
* Mint NUT codes meaning "a parallel operation currently holds these proofs/outputs".
* The work has NOT completed — retrying (or recovering via `restore`) is correct.
* #1115 mapped 11004 / 11002 → transient; kept consistent here.
*/
const TRANSIENT_MINT_CODES: ReadonlySet<number> = new Set([
CashuErrorCodes.OUTPUTS_ARE_PENDING, // 11004
CashuErrorCodes.PROOFS_ARE_PENDING, // 11002
CashuErrorCodes.QUOTE_PENDING, // 20005 — quote still settling
]);

/**
* Mint NUT codes meaning "this already happened" — the desired end-state is reached,
* so recovery is a no-op (reconcile local state via `wallet.restore` / idempotent
* re-fetch). These must NOT be retried (the second attempt would error) and must NOT
* be surfaced as a user-facing failure.
*/
const ALREADY_RESOLVED_MINT_CODES: ReadonlySet<number> = new Set([
CashuErrorCodes.TOKEN_ALREADY_SPENT, // 11001
CashuErrorCodes.OUTPUT_ALREADY_SIGNED, // 11003
CashuErrorCodes.QUOTE_ALREADY_ISSUED, // 20002
CashuErrorCodes.INVOICE_ALREADY_PAID, // 20006
]);

/**
* Classify an arbitrary thrown value into one of the four recovery buckets.
*
* PURE: no side effects, no I/O, no logging — safe to call from anywhere (orchestrator,
* web hook, test). Unknown shapes return `'unhandled'` so the caller propagates rather
* than silently swallowing.
*
* @param err - the thrown value (typed `unknown`; this function narrows it).
* @returns the 4-bucket verdict.
*/
export function classify(err: unknown): ErrorClass {
// --- SDK-native errors -----------------------------------------------------
// ConcurrencyError is the SDK's own "stale, refetch + retry" signal (DB optimistic
// lock) — transient by definition. DomainError / NotFoundError are terminal.
if (err instanceof ConcurrencyError) {
return 'transient';
}
if (err instanceof DomainError || err instanceof NotFoundError) {
return 'permanent';
}

// --- cashu-ts protocol errors ---------------------------------------------
// MintOperationError extends HttpResponseError, so check it FIRST (most specific).
if (err instanceof MintOperationError) {
if (TRANSIENT_MINT_CODES.has(err.code)) {
return 'transient';
}
if (ALREADY_RESOLVED_MINT_CODES.has(err.code)) {
return 'already-resolved';
}
// Any other mint rejection (unbalanced tx, unit mismatch, keyset inactive,
// amount-out-of-limits, auth required, …) is a deterministic rejection: retrying
// the same request will fail identically. Surface it.
return 'permanent';
}

// A NetworkError or a non-mint HTTP error (timeout, 429, 5xx, connection refused)
// is a transport/connectivity failure — retry. Master's receive hook explicitly
// treats HTTP 429 as retryable.
if (err instanceof NetworkError || err instanceof HttpResponseError) {
return 'transient';
}

// --- unknown ---------------------------------------------------------------
return 'unhandled';
}
17 changes: 16 additions & 1 deletion packages/wallet-sdk/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* `SdkError` (base + `readonly code`) is NET-NEW (master's errors have no shared
* base / no `code`). `ConcurrencyError` / `DomainError` / `NotFoundError` are
* re-parented onto `SdkError` (master forms live in `app/features/shared/error.ts`).
* PR1 ships the class SHAPES only — empty bodies, no logic.
* These are the REAL runtime classes (consumed by `classify()` + the domain stubs).
*/

/**
Expand Down Expand Up @@ -34,3 +34,18 @@ export class DomainError extends SdkError {}

/** The requested entity does not exist. */
export class NotFoundError extends SdkError {}

/**
* A method that exists on the contract but whose implementation has not landed yet
* (a later build slice fills it in). Thrown by the domain stubs the `Sdk` shell wires
* in PR2 so calling an unimplemented method fails loudly + identifiably rather than
* returning `undefined`. NOT a runtime error model the orchestrator recovers from.
*/
export class NotImplementedError extends SdkError {
constructor(method: string) {
super(
`${method} is not implemented yet (wired by a later @agicash/wallet-sdk build slice)`,
'NOT_IMPLEMENTED',
);
}
}
9 changes: 6 additions & 3 deletions packages/wallet-sdk/src/events.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
/**
* Event layer — §11 of the contract. FULLY NET-NEW (no master EventEmitter).
*
* PR1 ships the `SdkEventMap` keys + the `EventEmitter<M>` INTERFACE (declaration
* only — no implementation). `BackgroundState` is defined here (used by
* `background:state` and `BackgroundDomain.state()`).
* This module owns the `SdkEventMap` keys + the PUBLIC `EventEmitter<M>` interface
* (subscribe-only: `on` / `once`). The runtime backing — `TypedEventEmitter<M>`, which
* also has the internal `emit` / `off` — lands in `./internal/event-emitter` (PR2). The
* `Sdk` exposes the instance typed as this narrow interface so consumers can subscribe
* but not publish. `BackgroundState` is defined here (used by `background:state` and
* `BackgroundDomain.state()`).
*/
import type { Account } from './types/account';
import type { Contact } from './types/contact';
Expand Down
Loading
Loading