-
Notifications
You must be signed in to change notification settings - Fork 5
wallet-sdk PR2: core (Money, errors+classify, events, Sdk shell + wiring) #1120
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'); | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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)