diff --git a/docs-src/usage/melt_token.md b/docs-src/usage/melt_token.md index 7695e1d3e..7d032e708 100644 --- a/docs-src/usage/melt_token.md +++ b/docs-src/usage/melt_token.md @@ -45,7 +45,8 @@ const { send: proofsToSend } = await wallet.send(amountToSend, proofs, { const meltPreview = await wallet.prepareMelt('bolt11', meltQuote, proofsToSend); -await saveMeltPreview(meltPreview); +// Persist an app-defined snapshot here. +// Do not call JSON.stringify(meltPreview) directly; preview objects contain non-JSON-safe values. const meltResponse = await wallet.completeMelt(meltPreview); ``` diff --git a/docs-src/usage/mint_token.md b/docs-src/usage/mint_token.md index 2f0b0a985..36c74818b 100644 --- a/docs-src/usage/mint_token.md +++ b/docs-src/usage/mint_token.md @@ -39,12 +39,13 @@ if (mintQuoteChecked.state !== MintQuoteState.PAID) { throw new Error('Mint quote is not paid yet'); } -const preview = await wallet.prepareMint('bolt11', 64, mintQuote.quote, undefined, { +const preview = await wallet.prepareMint('bolt11', 64, mintQuoteChecked, undefined, { type: 'deterministic', counter: 0, }); -// Persist `preview` here if you want to retry safely later. +// Persist an app-defined snapshot here if you want to retry safely later. +// Do not call JSON.stringify(preview) directly; preview objects contain non-JSON-safe values. const proofs = await wallet.completeMint(preview); ``` diff --git a/docs-src/usage/nut19.md b/docs-src/usage/nut19.md index 901f9d764..6e3386780 100644 --- a/docs-src/usage/nut19.md +++ b/docs-src/usage/nut19.md @@ -95,12 +95,16 @@ operations that create blinded outputs. ### Mint ```ts -const mintPreview = await wallet.prepareMint('bolt11', 64, quoteId, undefined, { +const mintQuote = await wallet.checkMintQuoteBolt11(quoteId); + +const mintPreview = await wallet.prepareMint('bolt11', 64, mintQuote, undefined, { type: 'deterministic', counter: 0, }); -await saveMintPreview(mintPreview); // your save function +// Persist an app-defined snapshot here. +// Do not call JSON.stringify(mintPreview) directly; preview objects contain +// Amount, bigint, Uint8Array, and class instances that need explicit rehydration. const proofs = await wallet.completeMint(mintPreview); ``` @@ -111,7 +115,9 @@ const meltPreview = await wallet.prepareMelt('bolt11', meltQuote, proofsToSend, includeFees: true, }); -await saveMeltPreview(meltPreview); // your save function +// Persist an app-defined serialized snapshot here. +// Do not call JSON.stringify(meltPreview) directly; preview objects contain +// Amount, bigint, Uint8Array, and class instances that need explicit rehydration. const result = await wallet.completeMelt(meltPreview); ``` diff --git a/etc/cashu-ts.api.md b/etc/cashu-ts.api.md index 1d48cce84..15cfe4982 100644 --- a/etc/cashu-ts.api.md +++ b/etc/cashu-ts.api.md @@ -47,7 +47,7 @@ export class Amount { // (undocumented) static sum(values: Iterable): Amount; toBigInt(): bigint; - toJSON(): number | string; + toJSON(): string; toNumber(): number; toNumberUnsafe(): number; toString(): string; @@ -60,7 +60,7 @@ export class AmountError extends Error { constructor(message: string); } -// @public (undocumented) +// @public export type AmountLike = number | bigint | string | Amount; // @public @@ -644,7 +644,7 @@ export const meetsSignerThreshold: (signatures: string[], message: string, pubke // @public export class MeltBuilder = MeltQuoteBolt11Response> { - constructor(wallet: Wallet, method: string, quote: TQuote, proofs: Proof[]); + constructor(wallet: Wallet, method: string, quote: TQuote, proofs: ProofLike[]); asCustom(data: OutputDataLike[]): this; asDeterministic(counter?: number, denoms?: AmountLike[]): this; asFactory(factory: OutputDataFactory, denoms?: AmountLike[]): this; @@ -828,7 +828,7 @@ export class MintBuilder): Promise : MintPreview>; privkey(k: string): MintBuilder; - proofsWeHave(p: Array>): this; + proofsWeHave(p: Array>): this; run(this: MintBuilder): Promise; } @@ -1004,7 +1004,7 @@ export interface MintPreview export type MintProofsConfig = { keysetId?: string; privkey?: string | string[]; - proofsWeHave?: Array>; + proofsWeHave?: Array>; onCountersReserved?: OnCountersReserved; }; @@ -1408,7 +1408,7 @@ export type PrivKey = Uint8Array | string; // @public export type Proof = { id: string; - amount: bigint; + amount: Amount; secret: string; C: string; dleq?: SerializedDLEQ; @@ -1475,7 +1475,7 @@ export type RawTransport = { // @public export class ReceiveBuilder { - constructor(wallet: Wallet, token: Token | string | Proof[]); + constructor(wallet: Wallet, token: Token | string | ProofLike[]); asCustom(data: OutputDataLike[]): this; asDeterministic(counter?: number, denoms?: AmountLike[]): this; asFactory(factory: OutputDataFactory, denoms?: AmountLike[]): this; @@ -1485,7 +1485,7 @@ export class ReceiveBuilder { onCountersReserved(cb: OnCountersReserved): this; prepare(): Promise; privkey(k: string | string[]): this; - proofsWeHave(p: Array>): this; + proofsWeHave(p: Array>): this; requireDleq(on?: boolean): this; run(): Promise; } @@ -1495,7 +1495,7 @@ export type ReceiveConfig = { keysetId?: string; privkey?: string | string[]; requireDleq?: boolean; - proofsWeHave?: Array>; + proofsWeHave?: Array>; onCountersReserved?: OnCountersReserved; }; @@ -1563,14 +1563,14 @@ export type SecretKind = 'P2PK' | 'HTLC' | (string & {}); export type SecretsPolicy = 'auto' | 'deterministic' | 'random'; // @public (undocumented) -export type SelectProofs = (proofs: Proof[], amountToSelect: AmountLike, keyChain: KeyChain, includeFees?: boolean, exactMatch?: boolean, logger?: Logger) => SendResponse; +export type SelectProofs = (proofs: ProofLike[], amountToSelect: AmountLike, keyChain: KeyChain, includeFees?: boolean, exactMatch?: boolean, logger?: Logger) => SendResponse; // @public (undocumented) -export function selectProofsRGLI(proofs: Proof[], amountToSelect: AmountLike, keyChain: KeyChain, includeFees?: boolean, exactMatch?: boolean, _logger?: Logger): SendResponse; +export function selectProofsRGLI(proofs: ProofLike[], amountToSelect: AmountLike, keyChain: KeyChain, includeFees?: boolean, exactMatch?: boolean, _logger?: Logger): SendResponse; // @public export class SendBuilder { - constructor(wallet: Wallet, amount: AmountLike, proofs: Proof[]); + constructor(wallet: Wallet, amount: AmountLike, proofs: ProofLike[]); asCustom(data: OutputDataLike[]): this; asDeterministic(counter?: number, denoms?: AmountLike[]): this; asFactory(factory: OutputDataFactory, denoms?: AmountLike[]): this; @@ -1588,7 +1588,7 @@ export class SendBuilder { onCountersReserved(cb: OnCountersReserved): this; prepare(): Promise; privkey(k: string | string[]): this; - proofsWeHave(p: Array>): this; + proofsWeHave(p: Array>): this; run(): Promise; } @@ -1597,7 +1597,7 @@ export type SendConfig = { keysetId?: string; privkey?: string | string[]; includeFees?: boolean; - proofsWeHave?: Array>; + proofsWeHave?: Array>; onCountersReserved?: OnCountersReserved; }; @@ -1620,7 +1620,7 @@ export type SendResponse = { // @public export type SerializedBlindedMessage = { - amount: bigint; + amount: Amount; B_: string; id: string; }; @@ -1735,7 +1735,7 @@ export type SubscribeOpts = { export type SubscriptionCanceller = () => void; // @public -export function sumProofs(proofs: Array>): Amount; +export function sumProofs(proofs: Array>): Amount; // @public export type SwapMethod = { @@ -1752,8 +1752,8 @@ export type SwapMethod = { // @public export type SwapPreview = { - amount: AmountLike; - fees: AmountLike; + amount: Amount; + fees: Amount; keysetId: string; inputs: Proof[]; sendOutputs?: OutputDataLike[]; @@ -1909,9 +1909,9 @@ export class Wallet { loadMintFromCache(mintInfo: GetInfoResponse, cache: KeyChainCache): void; // (undocumented) get logger(): Logger; - meltProofs>(method: string, meltQuote: TQuote, proofsToSend: Proof[], config?: MeltProofsConfig, outputType?: OutputType): Promise>; - meltProofsBolt11(meltQuote: MeltQuoteBolt11Response, proofsToSend: Proof[], config?: MeltProofsConfig, outputType?: OutputType): Promise>; - meltProofsBolt12(meltQuote: MeltQuoteBolt12Response, proofsToSend: Proof[], config?: MeltProofsConfig, outputType?: OutputType): Promise>; + meltProofs>(method: string, meltQuote: TQuote, proofsToSend: ProofLike[], config?: MeltProofsConfig, outputType?: OutputType): Promise>; + meltProofsBolt11(meltQuote: MeltQuoteBolt11Response, proofsToSend: ProofLike[], config?: MeltProofsConfig, outputType?: OutputType): Promise>; + meltProofsBolt12(meltQuote: MeltQuoteBolt12Response, proofsToSend: ProofLike[], config?: MeltProofsConfig, outputType?: OutputType): Promise>; readonly mint: Mint; mintProofs>(method: string, amount: AmountLike, quote: TQuote, config?: MintProofsConfig, outputType?: OutputType): Promise; mintProofsBolt11(amount: AmountLike, quote: string | MintQuoteBolt11Response, config?: MintProofsConfig, outputType?: OutputType): Promise; @@ -1924,19 +1924,19 @@ export class Wallet { amount: AmountLike; quote: TQuote; }>, config?: MintProofsConfig, outputType?: OutputType): Promise>; - prepareMelt>(method: string, meltQuote: TQuote, proofsToSend: Proof[], config?: MeltProofsConfig, outputType?: OutputType): Promise>; + prepareMelt>(method: string, meltQuote: TQuote, proofsToSend: ProofLike[], config?: MeltProofsConfig, outputType?: OutputType): Promise>; prepareMint>(method: string, amount: AmountLike, quote: TQuote, config?: MintProofsConfig, outputType?: OutputType): Promise>; - prepareSwapToReceive(token: Token | string | Proof[], config?: ReceiveConfig, outputType?: OutputType): Promise; - prepareSwapToSend(amount: AmountLike, proofs: Proof[], config?: SendConfig, outputConfig?: OutputConfig): Promise; - receive(token: Token | string | Proof[], config?: ReceiveConfig, outputType?: OutputType): Promise; + prepareSwapToReceive(token: Token | string | ProofLike[], config?: ReceiveConfig, outputType?: OutputType): Promise; + prepareSwapToSend(amount: AmountLike, proofs: ProofLike[], config?: SendConfig, outputConfig?: OutputConfig): Promise; + receive(token: Token | string | ProofLike[], config?: ReceiveConfig, outputType?: OutputType): Promise; restore(start: number, count: number, config?: RestoreConfig): Promise<{ proofs: Proof[]; lastCounterWithSignature?: number; }>; selectProofsToSend(proofs: Proof[], amountToSend: AmountLike, includeFees?: boolean, exactMatch?: boolean): SendResponse; - send(amount: AmountLike, proofs: Proof[], config?: SendConfig, outputConfig?: OutputConfig): Promise; - sendOffline(amount: AmountLike, proofs: Proof[], config?: SendOfflineConfig): SendResponse; - signP2PKProofs(proofs: Proof[], privkey: string | string[], outputData?: OutputDataLike[], quoteId?: string): Proof[]; + send(amount: AmountLike, proofs: ProofLike[], config?: SendConfig, outputConfig?: OutputConfig): Promise; + sendOffline(amount: AmountLike, proofs: ProofLike[], config?: SendOfflineConfig): SendResponse; + signP2PKProofs(proofs: ProofLike[], privkey: string | string[], outputData?: OutputDataLike[], quoteId?: string): Proof[]; get unit(): string; withKeyset(id: string, opts?: { counterSource?: CounterSource; @@ -1995,17 +1995,17 @@ export class WalletEvents { export class WalletOps { constructor(wallet: Wallet); // (undocumented) - meltBolt11(quote: MeltQuoteBolt11Response, proofs: Proof[]): MeltBuilder; + meltBolt11(quote: MeltQuoteBolt11Response, proofs: ProofLike[]): MeltBuilder; // (undocumented) - meltBolt12(quote: MeltQuoteBolt12Response, proofs: Proof[]): MeltBuilder; + meltBolt12(quote: MeltQuoteBolt12Response, proofs: ProofLike[]): MeltBuilder; // (undocumented) mintBolt11(amount: AmountLike, quote: MintQuoteFor<'bolt11'>): MintBuilder<"bolt11", true>; // (undocumented) mintBolt12(amount: AmountLike, quote: MintQuoteFor<'bolt12'>): MintBuilder<"bolt12", false>; // (undocumented) - receive(token: Token | string | Proof[]): ReceiveBuilder; + receive(token: Token | string | ProofLike[]): ReceiveBuilder; // (undocumented) - send(amount: AmountLike, proofs: Proof[]): SendBuilder; + send(amount: AmountLike, proofs: ProofLike[]): SendBuilder; } // @public diff --git a/examples/auth_mint/auth_device_example.ts b/examples/auth_mint/auth_device_example.ts index 4ec554d7f..bf815479c 100644 --- a/examples/auth_mint/auth_device_example.ts +++ b/examples/auth_mint/auth_device_example.ts @@ -82,7 +82,7 @@ async function main() { const proofs = await wallet.mintProofsBolt11(100, request); console.log( '\nMinted 100 sats.', - proofs.map((p) => p.amount), + proofs.map((p) => p.amount.toString()), ); console.log(`\nMinted BATs in pool: ${auth.exportPool().length}`); @@ -94,7 +94,7 @@ async function main() { const response = await wallet.receive(encoded); console.log( '\nReceived 10 sats.', - response.map((p) => p.amount), + response.map((p) => p.amount.toString()), ); console.log(`\nMinted BATs in pool: ${auth.exportPool().length}`); diff --git a/examples/auth_mint/auth_password_example.ts b/examples/auth_mint/auth_password_example.ts index d8e7a089f..fae3e53ec 100644 --- a/examples/auth_mint/auth_password_example.ts +++ b/examples/auth_mint/auth_password_example.ts @@ -73,7 +73,7 @@ async function main() { const proofs = await wallet.mintProofsBolt11(100, request); console.log( '\nMinted 100 sats.', - proofs.map((p) => p.amount), + proofs.map((p) => p.amount.toString()), ); console.log(`\nMinted BATs in pool: ${auth.exportPool().length}`); @@ -85,7 +85,7 @@ async function main() { const response = await wallet.receive(encoded); console.log( '\nReceived 10 sats.', - response.map((p) => p.amount), + response.map((p) => p.amount.toString()), ); console.log(`\nMinted BATs in pool: ${auth.exportPool().length}`); diff --git a/examples/auth_mint/auth_pkce_example.ts b/examples/auth_mint/auth_pkce_example.ts index 1265a5999..cc181ff98 100644 --- a/examples/auth_mint/auth_pkce_example.ts +++ b/examples/auth_mint/auth_pkce_example.ts @@ -97,7 +97,7 @@ async function main() { const proofs = await wallet.mintProofsBolt11(100, request); console.log( '\nMinted 100 sats.', - proofs.map((p) => p.amount), + proofs.map((p) => p.amount.toString()), ); console.log(`\nMinted BATs in pool: ${auth.exportPool().length}`); @@ -110,7 +110,7 @@ async function main() { const response = await wallet.receive(encoded); console.log( '\nReceived 10 sats.', - response.map((p) => p.amount), + response.map((p) => p.amount.toString()), ); console.log(`\nMinted BATs in pool: ${auth.exportPool().length}`); diff --git a/examples/bolt12Wallet_example.ts b/examples/bolt12Wallet_example.ts index 867296616..a79b81aee 100644 --- a/examples/bolt12Wallet_example.ts +++ b/examples/bolt12Wallet_example.ts @@ -55,7 +55,7 @@ const runBolt12WalletExample = async () => { const newProofs = await mintFromBolt12Quote(wallet, bolt12MintQuote); proofs.push(...newProofs); - console.log(`💰 Balance: ${sumProofs(proofs)} sats\n`); + console.log(`💰 Balance: ${sumProofs(proofs).toString()} sats\n`); if (cycle < PAYMENT_CYCLES) { await new Promise((resolve) => setTimeout(resolve, 1000)); @@ -69,8 +69,8 @@ const runBolt12WalletExample = async () => { // Final summary console.log('🎯 Summary'); console.log('=========='); - console.log(`💰 Final balance: ${sumProofs(proofs)} sats`); - console.log(`📤 Total sent: ${totalSent} sats`); + console.log(`💰 Final balance: ${sumProofs(proofs).toString()} sats`); + console.log(`📤 Total sent: ${totalSent.toString()} sats`); console.log(`✅ BOLT12 example completed!`); } catch (error) { console.error('❌ Error:', error); @@ -101,7 +101,7 @@ const mintInitialProofs = async (wallet: Wallet): Promise => { console.log(`Pay this invoice: ${bolt11Quote.request}`); const proofs = await waitForMintQuote(wallet, bolt11Quote.quote); - console.log(`✅ Minted ${sumProofs(proofs)} sats`); + console.log(`✅ Minted ${sumProofs(proofs).toString()} sats`); return proofs; }; @@ -117,14 +117,18 @@ const payBolt12Offer = async ( const totalNeeded = meltQuote.amount.add(meltQuote.fee_reserve); if (sumProofs(proofs).lessThan(totalNeeded)) { - throw new Error(`Insufficient balance: need ${totalNeeded}, have ${sumProofs(proofs)}`); + throw new Error( + `Insufficient balance: need ${totalNeeded.toString()}, have ${sumProofs(proofs).toString()}`, + ); } // Send payment const { keep, send } = await wallet.send(totalNeeded, proofs, { includeFees: true }); const { change } = await wallet.meltProofsBolt12(meltQuote, send); - console.log(`💸 Paid ${amount} sats to BOLT12 offer (fee: ${meltQuote.fee_reserve} sats)`); + console.log( + `💸 Paid ${amount} sats to BOLT12 offer (fee: ${meltQuote.fee_reserve.toString()} sats)`, + ); return { remainingProofs: [...keep, ...change], diff --git a/examples/paymentApi_example.js b/examples/paymentApi_example.js index 751d2b1b7..151636203 100644 --- a/examples/paymentApi_example.js +++ b/examples/paymentApi_example.js @@ -58,7 +58,7 @@ export const ecashPayment = onRequest(async (req, res) => { res.json({ success: false, error: 'wrong_amount', - message: `Wrong amount, must be ${waitedAmount} satoshi`, + message: `Wrong amount, must be ${waitedAmount.toString()} satoshi`, }); return; } diff --git a/examples/simpleWallet_example.ts b/examples/simpleWallet_example.ts index 3449e754f..c32e200dc 100644 --- a/examples/simpleWallet_example.ts +++ b/examples/simpleWallet_example.ts @@ -70,7 +70,7 @@ const runWalletExample = async () => { if (quote.state === MintQuoteState.PAID) { //if the quote was paid, we can ask the mint to issue the signatures for the ecash const response = await wallet.mintProofsBolt11(mintAmount, quote.quote); - console.log(`minted proofs: ${response.map((p) => p.amount).join(', ')} sats`); + console.log(`minted proofs: ${response.map((p) => p.amount.toString()).join(', ')} sats`); // let's store the proofs in the storage we previously created proofs = response; @@ -153,9 +153,9 @@ const runWalletExample = async () => { // After creating the melt quote, we can initiate the melting process. const amountToMelt = quote.amount.add(quote.fee_reserve); - console.log(`quote amount: ${quote.amount}`); - console.log(`fee reserve proofs: ${quote.fee_reserve}`); - console.log(`Total quote amount: ${amountToMelt}`); + console.log(`quote amount: ${quote.amount.toString()}`); + console.log(`fee reserve proofs: ${quote.fee_reserve.toString()}`); + console.log(`Total quote amount: ${amountToMelt.toString()}`); // in order to get the correct amount of proofs for the melt request, we can use the `send` function we used before const { keep, send } = await wallet.send(amountToMelt, proofs, { diff --git a/migration-4.0.0.SKILL.md b/migration-4.0.0.SKILL.md index 2e4d666ec..95771da76 100644 --- a/migration-4.0.0.SKILL.md +++ b/migration-4.0.0.SKILL.md @@ -1,6 +1,6 @@ --- name: cashu-ts-migrate-v3-to-v4 -description: This skill should be used when an agent needs to "upgrade cashu-ts from v3 to v4", "migrate @cashu/cashu-ts to version 4", "apply the cashu-ts v4 breaking changes", or "update a codebase to use cashu-ts 4.0.0". Provides a step-by-step mechanical recipe to locate and fix every breaking change introduced in v4. +description: Use this skill to "upgrade cashu-ts from v3 to v4" in a JS/TS codebase. Provides a step-by-step guide to fix every breaking change introduced in v4. version: 1.0.0 --- @@ -25,7 +25,7 @@ Flag any `require(...)` hits — v4 is **ESM-only** (Step 1). ## Step 0b — Confirm `Amount` strategy -v4 introduces an `Amount` value object (bigint-backed, immutable) wherever the library previously returned or accepted a plain `number`. This is intentional: it supports amounts above `Number.MAX_SAFE_INTEGER` (e.g. millisatoshi accumulations) without silent precision loss. +v4 introduces an immutable, bigint-backed `Amount` value object wherever the library previously returned or accepted a plain `number`. This avoids silent precision loss above `Number.MAX_SAFE_INTEGER` (for example, large millisatoshi totals). `Amount` is immutable, bigint-backed, and non-negative. It provides: @@ -39,13 +39,13 @@ v4 introduces an `Amount` value object (bigint-backed, immutable) wherever the l > v4 returns `Amount` objects from several APIs (see Step 3). Do you want the app to: > -> a) **Adopt `Amount` natively** — keep `Amount` flowing through your own functions and types; call `.toNumber()` only at genuine display/float-math boundaries. Best for apps that may handle large amounts. +> a) **Adopt `Amount` natively** — keep `Amount` flowing through your own functions and types; use `Amount` helpers for arithmetic and call `.toNumber()` only at genuine number-only boundaries. Best for apps that may handle large amounts. > > b) **Convert back to `number` at the boundary** — call `.toNumber()` immediately on every `Amount` the library returns, preserving your existing `number`-typed code. Fine if your amounts will always be within safe-integer range. -Record the user's choice. It affects how you handle every `Amount` hit in Steps 3–5: +Record the user's choice. It affects every `Amount` hit in Steps 3–5: -- Choice **a**: propagate `Amount` / `AmountLike` through the app's own functions and types; use `.toNumber()` only for float arithmetic (fee percentages etc.) and `Intl.NumberFormat` display of decimal units. Use `.toBigInt()` for integer units (SAT, JPY) passed to `Intl.NumberFormat` — it supports `bigint` natively. +- Choice **a**: propagate `Amount` / `AmountLike` through the app's own functions and types; use `Amount` helpers for arithmetic and call `.toNumber()` only at genuine number-only boundaries. For display, prefer string-safe formatting; for integer units (SAT, JPY), avoid eager `.toNumber()` and use runtime-appropriate bigint/string formatting rather than assuming `Intl.NumberFormat` bigint support. - Choice **b**: apply `.toNumber()` at each library call-site and leave all internal types as `number`. --- @@ -58,21 +58,13 @@ Apply these rules throughout the migration: - `Amount` represents a **non-negative integer magnitude** - `Amount.from(...)` accepts `AmountLike`: `number | bigint | string | Amount` -- string input must be a **non-negative decimal integer** -- negative strings like `"-42"` are invalid and will throw +- string input must be a non-negative decimal integer -Do **not** use `Amount` itself to represent signed debit/credit values. +Model sign separately; do not use `Amount` itself for signed debit/credit values. ### `AmountLike` is magnitude-only -`AmountLike` is: - -- `number | bigint | string | Amount` - -It is a flexible input type for magnitudes. It is **not** a signed amount type. -If the app has incoming/outgoing or plus/minus semantics, model sign separately. - -`AmountLike` is primarily a boundary type. Use it when accepting integer input from JSON, storage, user input, or external APIs, then normalize back to `Amount` for domain logic. +`AmountLike` is `number | bigint | string | Amount`. It is a magnitude boundary type, not a signed amount type. Use it for integer input from JSON, storage, user input, or external APIs, then normalize back to `Amount` for domain logic. eg: @@ -81,28 +73,29 @@ const someinteger: AmountLike = ...; // boundary variable const amount = Amount.from(someinteger); // bigint backed VO ``` -### Keep `Amount` in memory; use `JSONInt` at JSON boundaries +### Keep `Amount` in memory; choose JSON handling deliberately Default migration posture: - domain logic: `Amount` -- persistence / transport JSON: `JSONInt.parse` / `JSONInt.stringify` +- minimal migrations / app storage: plain JSON is acceptable because `Amount.toJSON()` always emits a decimal string (previously it returned `number` for safe integers, now always `string`) +- integer-preserving transport or persistence: prefer `JSONInt.parse` / `JSONInt.stringify` - UI formatting: `Amount` or sign + `Amount` -Do not flatten everything back to `number` unless the user explicitly chose that strategy in Step 0b. +If you round-trip an `Amount` through plain JSON at a leaf field, rehydrate it with `Amount.from(...)`. Do not flatten everything back to `number` unless the user explicitly chose that strategy in Step 0b. ### Choose number conversion deliberately - `toNumber()` = safe or throw - `toNumberUnsafe()` = accept precision loss -Use `toNumber()` for protocol or persistence boundaries that must not lie. Use `toNumberUnsafe()` only where lossy output is explicitly acceptable. +Use `toNumber()` for boundaries that must not lie. Use `toNumberUnsafe()` only where lossy output is explicitly acceptable. ### Agent guardrails - Never call `Amount.from()` on a signed string - Never assume `AmountLike` accepts negative values -- Never use plain `JSON.stringify` / `JSON.parse` for bigint-bearing persisted state if `JSONInt` is available +- Prefer `JSONInt.stringify` / `JSONInt.parse` for integer-bearing payloads when you want numeric/bigint fidelity after parse - Prefer bigint/string-safe formatting over eager `.toNumber()` for display --- @@ -124,16 +117,16 @@ Ensure `package.json` has `"type": "module"` or the bundler outputs ESM. --- -## Step 2 — `Proof.amount`: `number` → `bigint` +## Step 2 — `Proof.amount`: `number` → `Amount` Search: `\.amount` near proof construction/access; `amount:` in proof literals. Actions: -- Change proof literal amounts: `amount: 1000` → `amount: 1000n` -- Change accumulator seeds: `reduce((sum, p) => sum + p.amount, 0)` → `…, 0n)` -- Wrap for display: `Number(proof.amount)` -- `ProofLike` is a new exported type: `Omit & { amount: AmountLike }` — a proof where `amount` is not yet `bigint`. +- Change proof literal amounts: `amount: 1000` → `amount: Amount.from(1000)` +- Change accumulators: `reduce((sum, p) => sum + p.amount, 0)` → `reduce((sum, p) => sum.add(p.amount), Amount.zero())` or for proofs, use `sumProofs()`. +- Wrap for display or comparisons: `proof.amount.toString()`, `proof.amount.equals(1000)` +- `ProofLike` is `Omit & { amount: AmountLike }` — a proof whose `amount` is not yet normalized to `Amount`. - Use `serializeProofs`/`deserializeProofs` for proof serialization. `serializeProofs` returns `string[]` (one JSON string per proof). `deserializeProofs` accepts `string | string[] | ProofLike[]` — pass the raw JSON string directly (no `JSON.parse` needed), a `string[]` for individual proof strings, or a `ProofLike[]` for already-parsed objects: ```ts @@ -151,13 +144,17 @@ const proofs = deserializeProofs(event.tags.filter((t) => t[0] === 'proof').map( const proofs = deserializeProofs(db.query('SELECT * FROM proofs')); ``` -`normalizeProofAmounts(raw: ProofLike[])` is the lower-level building block that `deserializeProofs` uses internally. Call it directly when you already have typed `ProofLike[]` and want to skip the string-detection logic. +`normalizeProofAmounts(raw: ProofLike[])` is the lower-level helper behind `deserializeProofs`. Use it when you already have typed `ProofLike[]` and just need to normalize `amount` to `Amount`. + +Migration rule: treat wallet/mint/API/JSON proofs as `ProofLike[]` until normalized. Normalize before app-level arithmetic, encoding, or storage-model conversion. + +Core wallet flows now accept `ProofLike[]` directly. If those proofs are only being passed into wallet APIs such as `send`, `sendOffline`, `receive`, `prepareSwapToSend`, `meltProofs...`, or `signP2PKProofs`, you can often skip manual normalization. The same applies to `WalletOps` / builder entry points such as `wallet.ops.send(...)`, `wallet.ops.receive(...)`, and `wallet.ops.meltBolt11(...)`. --- ## Step 3 — `Amount` value object (was `number`) -Many methods now return `Amount` instead of `number`. See the full table in `migration-4.0.0.md`. +Many methods now return `Amount` instead of `number`. See `migration-4.0.0.md` for the full table. Key affected symbols: `sumProofs`, `getTokenMetadata().amount`, `OutputData.sumOutputAmounts`, @@ -172,29 +169,29 @@ const fee: number = wallet.getFeesForProofs(proofs).toNumber(); const total = sendAmt + fee; ``` -**Choice a** — propagate `Amount` through your own code; apply `.toNumber()` only at display and float-math boundaries: +**Choice a** — propagate `Amount` through your own code; use `Amount` helpers for arithmetic and call `.toNumber()` only at genuine number-only boundaries: ```ts const fee: Amount = wallet.getFeesForProofs(proofs); const total = Amount.from(sendAmt).add(fee); -// JSON serialisation is automatic — Amount.toJSON() emits a plain number +// JSON serialisation is automatic — Amount.toJSON() emits a string ``` If adopting Amount natively, see **Step 9** for Finance Helpers that replace common float patterns (`ceilPercent`, `floorPercent`, `scaledBy`, `clamp`, `inRange`). --- -## Step 4 — `SwapPreview.amount` / `.fees` now `AmountLike` +## Step 4 — `SwapPreview.amount` / `.fees` now `Amount` Search: `preview\.amount\b`, `preview\.fees\b` -Wrap before arithmetic: +If the preview came directly from the wallet, these fields are already `Amount`. If you persisted and later reloaded the preview, rehydrate before arithmetic. Only wrap the operand you call the method on: methods like `.subtract(...)` already accept `AmountLike` for the argument. ```ts // Before -const net = preview.amount.subtract(preview.fees); +const net = preview.amount - preview.fees; // After -const net = Amount.from(preview.amount).subtract(Amount.from(preview.fees)); +const net = Amount.from(preview.amount).subtract(preview.fees); ``` --- @@ -203,8 +200,7 @@ const net = Amount.from(preview.amount).subtract(Amount.from(preview.fees)); Search: `MintPreview`, `prepareMint` -`preview.quote` is now the full quote object (or `{ quote: string }` if a string ID was passed). -Access the ID via `preview.quote.quote`. Update any manually constructed `MintPreview` values: +`preview.quote` is now a quote object. If you only have a quote ID string, wrap it as `{ quote: string }` and access the ID via `preview.quote.quote`: ```ts // Before @@ -342,7 +338,7 @@ await wallet.loadMintFromCache(cache); --- -## Step 12 — Deprecated `Keyset` getters +## Step 12 — Deprecated `Keyset` class getters Search: `\.active\b`, `\.input_fee_ppk\b`, `\.final_expiry\b` @@ -352,6 +348,8 @@ Search: `\.active\b`, `\.input_fee_ppk\b`, `\.final_expiry\b` | `keyset.input_fee_ppk` | `keyset.fee` | | `keyset.final_expiry` | `keyset.expiry` | +Note: Ensure the app is referring to a Cashu-TS `Keyset` domain model. Some apps may be using the raw API `MintKeyset` / `MintKeys` DTOs, which have the same "old" fields! + --- ## Step 13 — Removed utility functions @@ -367,9 +365,9 @@ Key replacements: - `bytesToNumber(b)` → `Bytes.toBigInt(b)` - `verifyKeysetId(id, keys)` → `Keyset.verifyKeysetId(id, keys)` - `deriveKeysetId(keys, unit)` → `deriveKeysetId({ keys, unit })` -- `handleTokens(token)` → `getDecodedToken(token)` or `getTokenMetadata(token)` +- `handleTokens(token)` → `getTokenMetadata(token)` before a wallet exists, then `wallet.decodeToken(token)` after the wallet is loaded; use `getDecodedToken(token, keysetIds)` only in advanced flows - `getEncodedTokenV4(token)` → `getEncodedToken(token)` -- `MessageQueue` (from utils) → `import { MessageQueue } from '@cashu/cashu-ts/transport/WSConnection'` +- `MessageQueue` / `MessageNode` → remove direct imports and use supported `WSConnection` APIs instead --- @@ -473,14 +471,16 @@ const factory: OutputDataFactory = (amount: AmountLike, keys: HasKeysetKeys) => ## Step 18 — Type-check and test ```bash +# Usually, but check your app: npx tsc --noEmit npm test ``` -Remaining `number` / `bigint` mismatches on `Proof.amount` indicate stored proofs not yet +Remaining `AmountLike` / `Amount` mismatches on `Proof.amount` indicate stored proofs not yet normalized — use `deserializeProofs()` for JSON sources or `normalizeProofAmounts()` for -already-parsed objects (e.g. database rows). `Amount` type errors indicate `.toNumber()` or -`Amount.from()` wrapping is missing. +already-parsed objects (e.g. database rows). More generally, `Amount` type errors usually mean +either a boundary value needs `Amount.from(...)`, or code that previously used `number` now needs +to keep an `Amount` rather than converting it. --- diff --git a/migration-4.0.0.md b/migration-4.0.0.md index f4abb6350..2cacad3ee 100644 --- a/migration-4.0.0.md +++ b/migration-4.0.0.md @@ -2,13 +2,13 @@ ⚠️ Upgrading to version 4.0.0 will come with breaking changes! Please follow the migration guide for a smooth transition to the new version. -**TIP**: If you use a coding agent, you can point them to `migration.4.0.0.SKILL.md`. +**TIP**: If you use a coding agent, you can point them to `migration-4.0.0.SKILL.md`. --- ## The `Amount` value object — what changed and what it means for your app -The single most pervasive change in v4 is that many APIs which previously returned or accepted a plain `number` now use an `Amount` value object. This was a deliberate design choice: JavaScript `number` silently loses precision above `Number.MAX_SAFE_INTEGER` (2^53 - 1). While that limit is above the total Bitcoin supply in satoshis, it is reachable with millisatoshi accounting or high-volume stablecoin tokens — and a silent rounding error in a payment app is a serious bug. +Many v4 APIs that previously returned or accepted `number` now use `Amount`. This avoids silent precision loss above `Number.MAX_SAFE_INTEGER`, which matters for millisatoshi or other high-volume integer accounting. `Amount` is immutable, bigint-backed, and non-negative. It provides: @@ -23,89 +23,29 @@ The single most pervasive change in v4 is that many APIs which previously return Before you start updating call sites, decide how deeply you want to adopt `Amount`: **Option A — Adopt `Amount` natively (recommended for new or large-amount apps)** -Keep `Amount` flowing through your own functions and types. Convert to `number` only at boundaries that truly require a JavaScript number. For display, prefer bigint/string-safe formatting where possible: for integer-unit currencies like SAT, pass `.toBigInt()` directly to `Intl.NumberFormat`; for decimal or minor-unit currencies, use formatting helpers that preserve precision instead of eagerly calling `.toNumber()`. +Keep `Amount` flowing through your own functions and types. Use `Amount` helpers for arithmetic, and convert to `number` only at boundaries that truly require a JavaScript number. For display, prefer string-safe formatting where possible: for integer-unit currencies like SAT, avoid eager `.toNumber()` and use runtime-appropriate bigint/string formatting; for decimal or minor-unit currencies, use formatting helpers that preserve precision instead of eagerly calling `.toNumber()`. **Option B — Convert at the boundary (simplest for existing number-typed codebases)** Call `.toNumber()` immediately on every `Amount` the library returns, then leave all your internal types as `number`. Safe as long as your amounts stay within `Number.MAX_SAFE_INTEGER`. Both strategies are valid. The sections below show the mechanical changes required; the key question is whether you propagate `Amount` inward or flatten it at the edge. -### Recommendations for "Option A" +### Practical `Amount` rules -The following notes may help you plan the migration to bigint support in your app. - -#### 1. `Amount` is non-negative only - -`Amount` is a bigint-backed value object for **non-negative integer magnitudes**. - -- `Amount.from(...)` accepts `AmountLike`: `number | bigint | string | Amount` -- string input must be a **non-negative decimal integer** -- negative strings like `"-42"` are invalid and will throw -- do not use `Amount` itself to represent signed debit/credit values - -#### 2. `AmountLike` is magnitude-only input - -`AmountLike` is: - -- `number | bigint | string | Amount` - -It exists so APIs can accept integer magnitudes flexibly. It is **not** a signed amount type. -If your app has incoming/outgoing, debit/credit, or plus/minus semantics, model sign separately. - -`AmountLike` is primarily a boundary type. Use it when accepting integer input from JSON, storage, user input, or external APIs, then normalize back to `Amount` for domain logic. - -eg: +- `Amount` is for non-negative integer magnitudes only. Model sign separately. +- `AmountLike` is a boundary type: `number | bigint | string | Amount`. +- Normalize external input with `Amount.from(...)`, then keep `Amount` in domain logic. +- Plain JSON is acceptable for minimal migrations because `Amount.toJSON()` emits a decimal string. +- If you round-trip an `Amount` through plain JSON, rehydrate it with `Amount.from(...)`. +- Prefer `JSONInt.stringify` / `JSONInt.parse` for persisted or transported integer-bearing payloads when you want numeric/bigint fidelity after parse. +- `toNumber()` is safe-or-throw; `toNumberUnsafe()` is explicitly lossy. +- For display, prefer string-safe formatting and avoid eager `.toNumber()`. ```ts -const someinteger: AmountLike = ...; // boundary variable -const amount = Amount.from(someinteger); // bigint backed VO +const raw: AmountLike = getExternalAmount(); +const amount = Amount.from(raw); ``` -#### 3. Keep `Amount` in memory; convert only at true boundaries - -Best practice: - -- domain logic: `Amount` -- persistence / transport JSON: `JSONInt` -- UI formatting: `Amount` or sign + `Amount` - -Do not flatten everything back to `number` unless you have consciously chosen a safe-integer-only strategy. - -#### 4. `toNumber()` vs `toNumberUnsafe()` is an explicit policy choice - -- `toNumber()` = safe or throw -- `toNumberUnsafe()` = accept precision loss - -Use `toNumber()` when a boundary must not lie. Use `toNumberUnsafe()` only when lossy output is acceptable. Prefer `toString()`, `toBigInt()`, or `toJSON()` when possible. - -#### 5. `JSONInt` is the default JSON boundary for integer-bearing payloads - -Use `JSONInt.stringify` / `JSONInt.parse` for: - -- localStorage -- IndexedDB snapshots -- backup/export/import files -- Nostr / NWC / event payloads -- any persisted or transported object graph that may contain bigint-backed values - -Do not rely on plain `JSON.stringify` / `JSON.parse` for bigint-bearing structures if `JSONInt` is available. - -#### 6. `Amount.toJSON()` helps, but it is not your app's full JSON policy - -`Amount.toJSON()` emits: - -- `number` for safe integers -- decimal `string` for larger values - -That solves leaf-value emission, but apps still need a consistent whole-payload JSON policy. That policy should be `JSONInt`. - -#### 7. For display, prefer bigint/string-safe formatting - -Do not immediately call `toNumber()` just to render an integer amount. - -- For integer units, `Intl.NumberFormat` supports `bigint` -- For minor-unit currencies, prefer bigint/string-aware formatting helpers over unsafe `number` conversion - --- ## ESM-only package @@ -175,16 +115,20 @@ const n = meltQuote.amount; const sats = meltQuote.fee_reserve.add(meltQuote.amount).toNumber(); const n = meltQuote.amount.toNumber(); // throws if value > Number.MAX_SAFE_INTEGER -// Safe JSON serialisation: Amount.toJSON() emits a number for safe values, -// a decimal string for values above MAX_SAFE_INTEGER -JSON.stringify({ amount: meltQuote.amount }); // → '{"amount":1000}' +// Amount.toJSON() always emits a decimal string (previously number | string). +// This means JSON.stringify produces a quoted string, not a bare number: +JSON.stringify({ amount: meltQuote.amount }); // → '{"amount":"1000"}' (not '{"amount":1000}') + +// Rehydrate a JSON leaf value back to Amount +const parsed = JSON.parse('{"amount":"1000"}'); +const amount = Amount.from(parsed.amount); ``` --- -## `SerializedBlindedMessage.amount` is now `bigint` +## `SerializedBlindedMessage.amount` is now `Amount` -`SerializedBlindedMessage` is the outbound wire type sent to the mint. Its `amount` field is now typed as `bigint` (previously `number`) so that `JSONInt.stringify` always emits a raw numeric token — even for msat values above `Number.MAX_SAFE_INTEGER`. +`SerializedBlindedMessage` is the outbound wire type sent to the mint. Its `amount` field is now typed as `Amount` (previously `number`), consistent with the rest of the v4 amount model. This type is not typically constructed directly by application code; it is produced internally by `BlindedMessage.getSerializedBlindedMessage()`. If you build `SerializedBlindedMessage` objects manually, update the `amount` field: @@ -193,7 +137,7 @@ This type is not typically constructed directly by application code; it is produ const output: SerializedBlindedMessage = { amount: 1000, id: keysetId, B_: hex }; // After -const output: SerializedBlindedMessage = { amount: 1000n, id: keysetId, B_: hex }; +const output: SerializedBlindedMessage = { amount: Amount.from(1000), id: keysetId, B_: hex }; ``` ### Removed @@ -234,18 +178,18 @@ const n: number = total.toNumber(); // throws if value > Number.MAX_SAFE_INTEGER --- -## `SwapPreview.amount` and `SwapPreview.fees` are now `AmountLike` +## `SwapPreview.amount` and `SwapPreview.fees` are now `Amount` -Both fields on the `SwapPreview` type (returned by `prepareSend()` / `prepareReceive()`) are now typed as `AmountLike` rather than `Amount`. The wallet still returns `Amount` objects at runtime; the looser type allows deserialized previews (where amounts are plain numbers) to satisfy the type without wrapping in `Amount.from()`. +Both fields on the `SwapPreview` type (returned by `prepareSwapToSend()` / `prepareSwapToReceive()`) are typed as `Amount`. -If you call `Amount` methods on these fields, wrap them first: +If you persist or deserialize previews yourself, rehydrate before calling `Amount` methods. In arithmetic expressions, you only need to rehydrate the operand you are invoking the method on: methods like `.subtract(...)` already accept `AmountLike` for the argument. ```ts -// Before — worked because the type was Amount -const net = preview.amount.subtract(preview.fees); +// Before +const net = preview.amount - preview.fees; -// After — use Amount.from() to restore arithmetic -const net = Amount.from(preview.amount).subtract(Amount.from(preview.fees)); +// After — if the preview came from JSON/storage +const net = Amount.from(preview.amount).subtract(preview.fees); const n: number = net.toNumber(); ``` @@ -265,19 +209,17 @@ const sats: number | undefined = request.amount?.toNumber(); --- -## Utility functions `splitAmount`, `getKeepAmounts`, and `getKeysetAmounts` now return `Amount[]` +## Utility functions `splitAmount` and `getKeysetAmounts` now return `Amount[]` -These functions in `@cashu/cashu-ts` previously returned `number[]`; they now return `Amount[]`. +These public functions in `@cashu/cashu-ts` previously returned `number[]`; they now return `Amount[]`. ```ts // Before const chunks: number[] = splitAmount(1000, keys); -const keep: number[] = getKeepAmounts(proofs, 500, keys, 3); const denominations: number[] = getKeysetAmounts(keyset); // After const chunks: Amount[] = splitAmount(1000, keys); -const keep: Amount[] = getKeepAmounts(proofs, 500, keys, 3); const denominations: Amount[] = getKeysetAmounts(keyset); // Convert to numbers where needed @@ -326,25 +268,27 @@ const mySelector: SelectProofs = (proofs, amountToSelect: AmountLike, ...) => { ## `MintPreview.quote` is now the full quote object -`prepareMint()` previously stored only the quote ID string in `MintPreview.quote`. It now stores the full quote object returned by the mint, giving consumers access to informational fields (`expiry`, `request`, `amount`, `unit`) needed for NUT-19 retry flows. +`prepareMint()` previously stored only the quote ID string in `MintPreview.quote`. It now stores the full quote object passed into `prepareMint()`, giving consumers access to informational fields (`expiry`, `request`, `amount`, `unit`) needed for NUT-19 retry flows. ```ts -// Before — quote was a plain string -const preview = await wallet.prepareMint('bolt11', 1000, quoteResponse); -preview.quote; // string (quote ID only) +// Before — quote was stored on the preview as a plain string +const previewV3: MintPreview = { ..., quote: 'q123' }; +previewV3.quote; // string (quote ID only) // After — quote is the full TQuote object when a quote object is passed const preview = await wallet.prepareMint('bolt11', 1000, quoteResponse); preview.quote.expiry; // number | null — accessible now preview.quote.request; // string — Lightning invoice -// If you passed a string quote ID, the field is { quote: string } -const preview2 = await wallet.prepareMint('bolt11', 1000, 'q123'); +// prepareMint() now expects a quote object, not a string ID +const preview2 = await wallet.prepareMint('bolt11', 1000, { quote: 'q123' }); preview2.quote; // { quote: 'q123' } ``` The type is `MintPreview` where `TQuote extends { quote: string }` (defaults to `MintQuoteBaseResponse`). +If you only have a bolt11 quote ID string, use `mintProofsBolt11(amount, quoteId)` rather than `prepareMint()`. + If you construct a `MintPreview` manually (e.g., after deserialization), update the `quote` field from a bare string to an object: ```ts @@ -355,11 +299,18 @@ const preview: MintPreview = { ..., quote: 'q123' }; const preview: MintPreview = { ..., quote: mintQuoteResponse }; ``` +Also note that preview objects are not intended for direct `JSON.stringify(...)`. +`MintPreview`, `MeltPreview`, `BatchMintPreview`, and `SwapPreview` contain values such as +`Amount`, `bigint`, `Uint8Array`, and class instances that need explicit rehydration. If you +persist previews for replay-safe recovery, serialize them into an app-defined snapshot format and +explicitly rehydrate them before passing them back to `completeMint()`, `completeMelt()`, or +`completeSwap()`. + --- -## `Proof.amount` is now `bigint` +## `Proof.amount` is now `Amount` -The `amount` field on the `Proof` type has changed from `number` to `bigint`. This affects any code that constructs, stores, or compares proof amounts. +The `amount` field on the `Proof` type has changed from `number` to `Amount`. This affects any code that constructs, stores, or compares proof amounts. ```ts // Before @@ -367,11 +318,14 @@ const proof: Proof = { id, amount: 1000, C, secret }; const total = proofs.reduce((sum, p) => sum + p.amount, 0); // After -const proof: Proof = { id, amount: 1000n, C, secret }; -const total = proofs.reduce((sum, p) => sum + p.amount, 0n); +const proof: Proof = { id, amount: Amount.from(1000), C, secret }; +const total: Amount = proofs.reduce((sum, p) => sum.add(p.amount), Amount.zero()); +// or more simply +const total: Amount = sumProofs(proofs); -// Convert to number when needed (e.g. display) -const display: number = Number(proof.amount); // safe for typical sat amounts +// Convert or compare explicitly when needed +const display: number = proof.amount.toNumber(); +const isExact = proof.amount.equals(1000); ``` If you persist proofs to JSON or a database, see the [Proof serialization](#proof-serialization) section below for the helper functions provided. @@ -399,9 +353,9 @@ const ksFee: Amount = wallet.getFeesForKeyset(3, keysetId); --- -## `MessageQueue` and `MessageNode` moved +## `MessageQueue` and `MessageNode` are no longer public top-level utils -`MessageQueue` and `MessageNode` are no longer exported from `@cashu/cashu-ts` utils. `MessageQueue` is now exported from the transport module (`src/transport/WSConnection.ts`). `MessageNode` is no longer part of the public API. +`MessageQueue` and `MessageNode` are no longer exported from `@cashu/cashu-ts` utils. `MessageNode` is no longer part of the public API, and `MessageQueue` should be treated as internal rather than migrated as a supported import. If you were importing these classes directly: @@ -409,9 +363,8 @@ If you were importing these classes directly: // Before import { MessageQueue, MessageNode } from '@cashu/cashu-ts'; -// After — MessageQueue is available from the transport module -import { MessageQueue } from '@cashu/cashu-ts/transport/WSConnection'; -// MessageNode is removed; use MessageQueue.enqueue/dequeue instead +// After +// Use supported WSConnection APIs instead of importing queue internals directly ``` --- @@ -453,21 +406,25 @@ The following are still exported but are excluded from the trimmed type definiti ### `handleTokens` no longer exported -`handleTokens` should always have been an internal function, but was exported. If you used this function, use `getDecodedToken` (for fully hydrated `Proof` objects) or `getTokenMetadata` (for token/proof metadata without keyset resolution) instead. +`handleTokens` should always have been an internal function, but was exported. If you used this function, prefer `getTokenMetadata` before a wallet exists, then `wallet.decodeToken(...)` after the wallet is loaded. Use `getDecodedToken(str, keysetIds)` only in advanced flows where you already manage keyset IDs yourself. --- ## Proof serialization -`ProofLike` is a new exported type: a proof-shaped object whose `amount` has not yet been normalized to `bigint` (i.e. `Omit & { amount: AmountLike }`). Use it to model proofs from external storage where `amount` may be a `number`, `string`, or `bigint`. +`ProofLike` is a new exported type: a proof-shaped object whose `amount` has not yet been normalized to `Amount` (i.e. `Omit & { amount: AmountLike }`). Use it to model proofs from external storage where `amount` may be a `number`, `string`, `bigint`, or `Amount`. Three helpers cover the common patterns for persisting and restoring proofs: -| Function | Use case | -| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `serializeProofs(proofs)` | Serialize `Proof \| Proof[]` to `string[]` (one JSON string per proof) without precision loss. | -| `deserializeProofs(json)` | Restore `string \| string[] \| ProofLike[]` back to `Proof[]`, with `amount` as `bigint`. Pass a raw JSON string directly (no `JSON.parse` needed), a `string[]` for individual proof strings (e.g. NutZap tags), or a `ProofLike[]` for already-parsed objects. | -| `normalizeProofAmounts(raw)` | Lower-level building block: convert `ProofLike[]` to `Proof[]` by normalizing `amount` to `bigint`. Called internally by `deserializeProofs`; use directly when you already have typed `ProofLike[]` and want to skip string-detection. | +| Function | Use case | +| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `serializeProofs(proofs)` | Serialize `Proof \| Proof[]` to `string[]` (one JSON string per proof) without precision loss. | +| `deserializeProofs(json)` | Restore `string \| string[] \| ProofLike[]` back to `Proof[]`, with `amount` normalized to `Amount`. Pass a raw JSON string directly (no `JSON.parse` needed), a `string[]` for individual proof strings (e.g. NutZap tags), or a `ProofLike[]` for already-parsed objects. | +| `normalizeProofAmounts(raw)` | Lower-level building block: convert `ProofLike[]` to `Proof[]` by normalizing `amount` to `Amount`. Called internally by `deserializeProofs`; use directly when you already have typed `ProofLike[]` and want to skip string-detection. | + +Migration rule: treat wallet/mint/API/JSON proofs as `ProofLike[]` until normalized. Normalize before app-level arithmetic, encoding, or storage-model conversion. + +**Tip**: Core wallet flows now accept `ProofLike[]` directly. If you already have deserialized proof objects from JSON or storage, you can usually pass them straight into wallet APIs such as `wallet.receive(...)`, `wallet.send(...)`, `wallet.sendOffline(...)`, `wallet.prepareSwapToSend(...)`, `wallet.meltProofs...(...)`, and `wallet.signP2PKProofs(...)` without calling `normalizeProofAmounts(...)` yourself first. The same applies to `WalletOps` / builder entry points such as `wallet.ops.send(...)`, `wallet.ops.receive(...)`, and `wallet.ops.meltBolt11(...)`. ```ts import { serializeProofs, deserializeProofs } from '@cashu/cashu-ts'; @@ -629,7 +586,7 @@ getEncodedToken(token, { version: 3 }); getEncodedToken({ mint, proofs: proofsWithBase64KeysetIds }); ``` -To resolve this, swap the proofs at the mint. `wallet.receive()` now accepts `Proof[]` directly, so no token string is needed: +To resolve this, swap the proofs at the mint. `wallet.receive()` now accepts proof arrays directly, including deserialized/stored `ProofLike[]`, so no token string is needed: ```ts const freshProofs = await wallet.receive(legacyProofs); @@ -698,7 +655,7 @@ These APIs were already deprecated in v3. In v4 they have been removed: - `Wallet` constructor preload options `keys`, `keysets`, and `mintInfo`; use `loadMintFromCache()` after construction. - Deprecated wallet method alias: `wallet.swap`; use `send`. -- `Keyset` getter aliases `active`, `input_fee_ppk`, and `final_expiry`; use `isActive`, `fee`, and `expiry`. +- `Keyset` getter aliases `active`, `input_fee_ppk`, and `final_expiry`; use `isActive`, `fee`, and `expiry`. Ensure you are looking at the Cashu-TS `Keyset` domain model: raw API `MintKeyset` / `MintKeys` DTOs may still expose the old field names. - `preferAsync` on melt option objects; set `prefer_async: true` in the melt payload or call `completeMelt(preview, privkey, true)`. - `MeltBlanks`, `wallet.on.meltBlanksCreated(cb)`, and `onChangeOutputsCreated`; use `prepareMelt()` / `completeMelt()` with `MeltPreview`. - Deprecated utility helpers and overloads in `src/utils/core`: `bytesToNumber`, `verifyKeysetId`, the positional `deriveKeysetId(...)` signature, and the `getDecodedToken(..., HasKeysetId[])` overload; use `Bytes.toBigInt`, `Keyset.verifyKeysetId(...)`, the options-based `deriveKeysetId(...)`, and `string[]` keyset IDs. @@ -921,58 +878,19 @@ For melt quotes, base fields (`amount`, `expiry`, `change`) are always normalize ## Finance Helpers — going further with `Amount` -Once you are propagating `Amount` natively, a set of Finance Helpers on the `Amount` class lets you replace common float-based patterns with exact integer arithmetic. All methods are chainable. +If you adopt `Amount` natively, it has methods to replace common float-based patterns with exact integer arithmetic: -### `ceilPercent(numerator, denominator = 100)` - -Returns `ceil(this × numerator / denominator)`. The default base of 100 makes integer percentages natural; use a larger denominator for fractional rates. +- `ceilPercent(numerator, denominator = 100)` for rounded-up percentages +- `floorPercent(numerator, denominator = 100)` for conservative lower bounds +- `scaledBy(numerator, denominator)` for proportional rescaling +- `clamp(min, max)` for bounding into a closed range +- `inRange(min, max)` for inclusive range checks ```ts -// Replaces: Math.ceil(amount * 0.02) -const fee = amount.ceilPercent(2); // ceil(2%) - -// Replaces: Math.ceil(amount * 0.005) -const fee = amount.ceilPercent(1, 200); // ceil(0.5%) - -// Fee with minimum: replaces Math.ceil(Math.max(2, amount * 0.02)) const fee = amount.ceilPercent(2).clamp(2, amount); -``` - -### `floorPercent(numerator, denominator = 100)` - -Returns `floor(this × numerator / denominator)`. The complement to `ceilPercent` — use when you need the conservative lower bound. - -```ts -// Replaces: Math.floor(amount * 0.98) -const maxSpend = amount.floorPercent(98); // floor(98%) -``` - -### `scaledBy(numerator, denominator)` - -Returns `round(this × numerator / denominator)` using integer arithmetic. Useful for proportional rescaling where the ratio is a runtime value. - -Uses the identity `round(a × b / c) = floor((2 × a × b + c) / (2 × c))` — no floats, no overflow risk. - -```ts -// Replaces: Math.round(estInvAmount * (tokenAmount / neededAmount)) - 1 +const maxSpend = amount.floorPercent(98); const adjusted = estInvAmount.scaledBy(tokenAmount, neededAmount).subtract(1); -``` - -### `clamp(min, max)` - -Bounds this amount to the inclusive range `[min, max]`. Throws if `min > max`. - -```ts -// Replaces: Amount.max(MIN_FEE, Amount.min(tokenAmount, fee)) const bounded = fee.clamp(MIN_FEE, tokenAmount); -``` - -### `inRange(min, max)` - -Returns `true` if this amount falls within `[min, max]` inclusive. Throws if `min > max`. - -```ts -// Replaces: minSendable <= msats && msats <= maxSendable if (msats.inRange(data.minSendable, data.maxSendable)) { ... } ``` diff --git a/src/mint/Mint.ts b/src/mint/Mint.ts index d88e21c6b..2aafc6c31 100644 --- a/src/mint/Mint.ts +++ b/src/mint/Mint.ts @@ -1006,7 +1006,7 @@ class Mint { ): SerializedBlindedMessage[] { return messages.map((message) => ({ ...message, - amount: Amount.from(message.amount).toBigInt(), + amount: Amount.from(message.amount), })); } diff --git a/src/model/Amount.ts b/src/model/Amount.ts index 00cd12d39..6c80b3b8e 100644 --- a/src/model/Amount.ts +++ b/src/model/Amount.ts @@ -5,6 +5,9 @@ export class AmountError extends Error { } } +/** + * All types that can be converted to an {@link Amount} value object. + */ export type AmountLike = number | bigint | string | Amount; /** @@ -35,8 +38,8 @@ export class Amount { /** * Parse/normalize supported inputs into an Amount. * - * @throws If input is negative, or `number` type input is above safe limit, or input is not a - * finite integer. + * @throws If input is negative, or if a `number` input exceeds the safe integer limit, or if + * input is not a finite integer. */ static from(input: AmountLike): Amount { if (input instanceof Amount) return input; @@ -124,10 +127,10 @@ export class Amount { } /** - * Returns number if a safe integer, string if not. + * Used by JSON.stringify() to convert Amount to string. */ - toJSON(): number | string { - return this.isSafeNumber() ? Number(this.value) : this.toString(); + toJSON(): string { + return this.toString(); } // ----------------------------------------------------------------- diff --git a/src/model/BlindedMessage.ts b/src/model/BlindedMessage.ts index 6653ac70f..9a7d47328 100644 --- a/src/model/BlindedMessage.ts +++ b/src/model/BlindedMessage.ts @@ -18,7 +18,7 @@ class BlindedMessage { } getSerializedBlindedMessage(): SerializedBlindedMessage { - return { amount: this.amountValue.toBigInt(), B_: this.B_.toHex(true), id: this.id }; + return { amount: this.amountValue, B_: this.B_.toHex(true), id: this.id }; } } export { BlindedMessage }; diff --git a/src/model/OutputData.ts b/src/model/OutputData.ts index f9b065998..21dbd2f30 100644 --- a/src/model/OutputData.ts +++ b/src/model/OutputData.ts @@ -138,7 +138,7 @@ export class OutputData implements OutputDataLike { const unblinded = constructUnblindedSignature(blindSig, this.blindingFactor, this.secret, A); const proof: Proof = { id: sig.id, - amount: sig.amount.toBigInt(), + amount: sig.amount, C: unblinded.C.toHex(true), secret: new TextDecoder().decode(unblinded.secret), ...(dleq && { diff --git a/src/model/SigAll.ts b/src/model/SigAll.ts index eb74edc36..b1f5d2eb8 100644 --- a/src/model/SigAll.ts +++ b/src/model/SigAll.ts @@ -5,9 +5,10 @@ import { schnorrSignDigest, } from '../crypto'; import { parseWitnessData } from '../crypto/NUT11'; -import { Bytes, encodeUint8toBase64Url, JSONInt } from '../utils'; +import { Bytes, JSONInt, encodeUint8toBase64Url } from '../utils'; import type { MeltPreview, SwapPreview } from '../wallet/types'; +import { Amount } from './Amount'; import type { P2PKWitness, Proof, MeltQuoteBaseResponse, SerializedBlindedMessage } from './types'; /** @@ -172,17 +173,16 @@ function deserializePackage( if (!output || typeof output !== 'object') throw new Error(`Invalid output at index ${i}`); - const amountType = typeof output.amount; - if (amountType !== 'number' && amountType !== 'bigint') { - throw new Error(`Output ${i}: amount must be a number`); + if (typeof output.amount !== 'number' && typeof output.amount !== 'bigint') { + throw new Error(`Output ${i}: amount must be a number or bigint`); } if (!output.B_ || typeof output.B_ !== 'string') throw new Error(`Output ${i}: B_ invalid`); if (!output.id || typeof output.id !== 'string') throw new Error(`Output ${i}: id invalid`); - // Rehydrate raw JSON token (number | bigint) to bigint to satisfy SerializedBlindedMessage contract. - output.amount = BigInt(output.amount as number | bigint); + // Rehydrate SerializedBlindedMessage.amount + output.amount = Amount.from(output.amount); } const digests = pkg.digests as Record | undefined; diff --git a/src/model/types/blinded.ts b/src/model/types/blinded.ts index ccfae213a..4de966308 100644 --- a/src/model/types/blinded.ts +++ b/src/model/types/blinded.ts @@ -5,10 +5,9 @@ import type { Amount } from '../Amount'; */ export type SerializedBlindedMessage = { /** - * Amount as a bigint so that JSONInt.stringify emits a raw numeric JSON token (never a quoted - * string) for values that exceed Number.MAX_SAFE_INTEGER (e.g. msat denominations). + * Amount denominated in keyset unit. */ - amount: bigint; + amount: Amount; /** * Blinded message. */ diff --git a/src/model/types/proof.ts b/src/model/types/proof.ts index 4277c299d..de33a6891 100644 --- a/src/model/types/proof.ts +++ b/src/model/types/proof.ts @@ -1,14 +1,14 @@ -import { type AmountLike } from '../Amount'; +import { type Amount, type AmountLike } from '../Amount'; import { type SerializedDLEQ } from './blinded'; /** - * A proof-shaped object whose `amount` field has not yet been normalized to `bigint`. + * A proof-shaped object whose `amount` field has not yet been normalized to `Amount`. * * Use this type to model proofs coming from external storage (localStorage, databases, JSON blobs) * where `amount` may be a `number`, `string`, or any other {@link AmountLike} value. * - * @see {@link Proof} for the fully normalized type with `amount: bigint`. + * @see {@link Proof} for the fully normalized type with `amount: Amount`. */ export type ProofLike = Omit & { amount: AmountLike }; @@ -23,7 +23,7 @@ export type Proof = { /** * Amount denominated in unit of the mints keyset id. */ - amount: bigint; + amount: Amount; /** * The initial secret that was (randomly) chosen for the creation of this proof. */ diff --git a/src/utils/JSONInt.ts b/src/utils/JSONInt.ts index cf47b6106..aad0804b4 100644 --- a/src/utils/JSONInt.ts +++ b/src/utils/JSONInt.ts @@ -1,3 +1,5 @@ +import { Amount } from '../model/Amount'; + /** * BigInt-safe JSON parser/stringifier. * @@ -442,7 +444,13 @@ function stringify( const serialize = (holder: Record, key: string): string | undefined => { let val: unknown = holder[key]; - if (isToJSONCapable(val)) { + // Amount VO: bypass toJSON() and emit as raw bigint → unquoted integer on the wire. + // This is intentional: the Cashu protocol requires unquoted numeric tokens for amounts, + // so JSONInt must emit e.g. 1000 not "1000". Plain JSON.stringify uses Amount.toJSON() + // which returns a quoted string — correct for app-level storage but not for wire format. + if (val instanceof Amount) { + val = val.toBigInt(); + } else if (isToJSONCapable(val)) { val = val.toJSON(key); } if (typeof replacer === 'function') { diff --git a/src/utils/core.ts b/src/utils/core.ts index b6cfed44a..6a4a9f665 100644 --- a/src/utils/core.ts +++ b/src/utils/core.ts @@ -201,12 +201,14 @@ function convertToShortKeysetId(proofs: Proof[]) { * Encodes a {@link Token} as a cashu token string. */ export function getEncodedToken(token: Token, opts?: { removeDleq?: boolean }): string { - if (hasNonHexId(token.proofs)) { + // Normalize amounts for untyped (JS) callers who may pass JSON.parse'd tokens directly. + const proofs = normalizeProofAmounts(token.proofs); + if (hasNonHexId(proofs)) { throw new Error( 'Proofs contain a legacy keyset ID and cannot be encoded. Swap them at the mint first.', ); } - return getEncodedTokenV4(token, opts?.removeDleq); + return getEncodedTokenV4({ ...token, proofs }, opts?.removeDleq); } /** @@ -260,7 +262,7 @@ function templateFromToken(token: Token): TokenV4Template { i: hexToBytes(id), p: idMap[id].map( (p: Proof): V4ProofTemplate => ({ - a: p.amount, + a: p.amount.toBigInt(), s: p.secret, c: hexToBytes(p.C), ...(p.dleq && { @@ -294,7 +296,7 @@ function tokenFromTemplate(template: TokenV4Template): Token { proofs.push({ secret: p.s, C: bytesToHex(p.c), - amount: Amount.from(p.a).toBigInt(), + amount: Amount.from(p.a), id: bytesToHex(t.i), ...(p.d && { dleq: { @@ -374,7 +376,7 @@ function handleTokens(token: string): Token { const entry = parsedV3Token.token[0]; const proofs = entry.proofs.map((p) => ({ ...p, - amount: Amount.from(p.amount as AmountLike).toBigInt(), + amount: Amount.from(p.amount as AmountLike), })); const tokenObj: Token = { mint: entry.mint, @@ -514,7 +516,7 @@ export function sanitizeUrl(url: string): string { /** * Sums the `amount` field of the given proofs. */ -export function sumProofs(proofs: Array>): Amount { +export function sumProofs(proofs: Array>): Amount { return Amount.sum(proofs.map((proof) => proof.amount)); } @@ -527,7 +529,7 @@ export function sumProofs(proofs: Array>): Amount { * const proofs = normalizeProofAmounts(db.query('SELECT * FROM proofs')); */ export function normalizeProofAmounts(raw: ProofLike[]): Proof[] { - return raw.map((p) => ({ ...p, amount: Amount.from(p.amount).toBigInt() })); + return raw.map((p) => ({ ...p, amount: Amount.from(p.amount) })); } /** @@ -664,7 +666,7 @@ export function hasValidDleq(proof: Proof, keyset: HasKeysetKeys): boolean { r: hexToNumber(proof.dleq.r ?? '00'), } as DLEQ; if (!hasCorrespondingKey(proof.amount, keyset.keys)) { - throw new Error(`Undefined key for amount ${proof.amount} in keyset ${keyset.id}`); + throw new Error(`Undefined key for amount ${proof.amount.toString()} in keyset ${keyset.id}`); } const key = keyset.keys[proof.amount.toString()]; return verifyDLEQProof_reblind( @@ -680,7 +682,9 @@ export function hasValidDleq(proof: Proof, keyset: HasKeysetKeys): boolean { */ export function getEncodedTokenBinary(token: Token): Uint8Array { const utf8Encoder = new TextEncoder(); - const template = templateFromToken(token); + // Normalize amounts for untyped (JS) callers who may pass JSON.parse'd tokens directly. + const proofs = normalizeProofAmounts(token.proofs); + const template = templateFromToken({ ...token, proofs }); const binaryTemplate = encodeCBOR(template); const prefix = utf8Encoder.encode('craw'); const version = utf8Encoder.encode('B'); diff --git a/src/wallet/SelectProofs.ts b/src/wallet/SelectProofs.ts index 35174d896..3c8849f20 100644 --- a/src/wallet/SelectProofs.ts +++ b/src/wallet/SelectProofs.ts @@ -1,13 +1,14 @@ // Minimal types to avoid importing the whole wallet, keeps this module independent import { fail, failIf, failIfNullish, type Logger, NULL_LOGGER, measureTime } from '../logger'; import { Amount, type AmountLike } from '../model/Amount'; -import type { Proof } from '../model/types/proof'; +import type { Proof, ProofLike } from '../model/types/proof'; +import { normalizeProofAmounts } from '../utils'; import { type KeyChain } from './KeyChain'; import { type SendResponse } from './types'; export type SelectProofs = ( - proofs: Proof[], + proofs: ProofLike[], amountToSelect: AmountLike, keyChain: KeyChain, includeFees?: boolean, @@ -16,13 +17,14 @@ export type SelectProofs = ( ) => SendResponse; export function selectProofsRGLI( - proofs: Proof[], + proofs: ProofLike[], amountToSelect: AmountLike, keyChain: KeyChain, includeFees: boolean = false, exactMatch: boolean = false, _logger: Logger = NULL_LOGGER, ): SendResponse { + const normalizedProofs = normalizeProofAmounts(proofs); const targetAmount = Amount.from(amountToSelect); const targetAmountNumber = targetAmount.toNumber(); @@ -44,7 +46,7 @@ export function selectProofsRGLI( // Caches proof amount (number) and fee interface ProofWithFee { proof: Proof; - amountNum: number; // Number(proof.amount) + amountNum: number; // proof.amount.toNumber() exFee: number; ppkfee: number; } @@ -123,10 +125,10 @@ export function selectProofsRGLI( */ let totalAmount = 0; let totalFeePPK = 0; - const proofWithFees = proofs.map((p) => { + const proofWithFees = normalizedProofs.map((p) => { // Guard: this algorithm uses number arithmetic throughout. Amounts above MAX_SAFE_INTEGER // (e.g. high-value proofs in msat-denomination mints) require a custom SelectProofs impl. - if (p.amount > BigInt(Number.MAX_SAFE_INTEGER)) { + if (p.amount.greaterThan(Number.MAX_SAFE_INTEGER)) { fail( 'selectProofsRGLI does not support proof amounts > Number.MAX_SAFE_INTEGER. ' + 'Provide a custom SelectProofs implementation for msat-scale wallets.', @@ -134,7 +136,7 @@ export function selectProofsRGLI( ); } const ppkfee = feeForProof(p); - const amountNum = Number(p.amount); // safe: guarded above + const amountNum = p.amount.toNumber(); // safe: guarded above const exFee = includeFees ? amountNum - ppkfee / 1000 : amountNum; const obj = { proof: p, amountNum, exFee, ppkfee }; // Sum all economical proofs (filtered below) @@ -182,7 +184,7 @@ export function selectProofsRGLI( // Validate using precomputed totals const totalNetSum = sumExFees(totalAmount, totalFeePPK); if (targetAmount.isZero() || targetAmountNumber > totalNetSum) { - return { keep: proofs, send: [] }; + return { keep: normalizedProofs, send: [] }; } // Max acceptable amount for non-exact matches @@ -323,9 +325,9 @@ export function selectProofsRGLI( if (bestSubset && bestDelta < Infinity) { const bestProofs = bestSubset.map((obj) => obj.proof); const bestSubsetSet = new Set(bestProofs); - const keep = proofs.filter((p) => !bestSubsetSet.has(p)); + const keep = normalizedProofs.filter((p) => !bestSubsetSet.has(p)); _logger.info(`Proof selection took ${timer.elapsed()}ms`); return { keep, send: bestProofs }; } - return { keep: proofs, send: [] }; + return { keep: normalizedProofs, send: [] }; } diff --git a/src/wallet/Wallet.ts b/src/wallet/Wallet.ts index 2023a8cd1..736a03d31 100644 --- a/src/wallet/Wallet.ts +++ b/src/wallet/Wallet.ts @@ -41,19 +41,20 @@ import type { SerializedBlindedSignature } from '../model/types/blinded'; import type { KeyChainCache } from '../model/types/keyset'; import { CheckStateEnum, type ProofState } from '../model/types/NUT07'; import { type BatchMintRequest } from '../model/types/NUT29'; -import type { Proof } from '../model/types/proof'; +import type { Proof, ProofLike } from '../model/types/proof'; import type { Token } from '../model/types/token'; import { getDecodedToken, hasValidDleq, invoiceHasAmountInHRP, + normalizeProofAmounts, sanitizeUrl, splitAmount, sumProofs, ABSOLUTE_MAX_BATCH_SIZE, } from '../utils'; -import { getKeepAmounts } from './_internal'; +import { getKeepAmounts, stringifyOutputTypeForLog } from './_internal'; import { type CounterSource, EphemeralCounterSource, @@ -622,9 +623,10 @@ class Wallet { keyset: Keyset, outputType: OutputType, includeFees: boolean = false, - proofsWeHave: Array> = [], + proofsWeHave: Array> = [], ): OutputType { let newAmount = this.parseAmount(amount, 'configureOutputs', true); + const normalizedProofsWeHave = proofsWeHave.map((p) => ({ amount: Amount.from(p.amount) })); // Custom outputs don't have automatic optimizations or fee inclusion) if (outputType.type === 'custom') { @@ -650,9 +652,9 @@ class Wallet { // If no denominations, but proofsWeHave was provided - optimize // to get around _denominationTarget proofs of each denomination. - if (denominations.length === 0 && proofsWeHave.length > 0) { + if (denominations.length === 0 && normalizedProofsWeHave.length > 0) { denominations = getKeepAmounts( - proofsWeHave, + normalizedProofsWeHave, newAmount, keyset.keys, this._denominationTarget, @@ -801,8 +803,9 @@ class Wallet { const indices = mergedBlindingData.map((_, i) => i); if (!isP2PKSigAll(inputs)) { indices.sort((a, b) => { - const aa = Amount.from(mergedBlindingData[a].blindedMessage.amount); - return aa.compareTo(mergedBlindingData[b].blindedMessage.amount); + return mergedBlindingData[a].blindedMessage.amount.compareTo( + mergedBlindingData[b].blindedMessage.amount, + ); }); } const keepVector: boolean[] = [ @@ -852,7 +855,7 @@ class Wallet { * @returns Newly minted proofs. */ async receive( - token: Token | string | Proof[], + token: Token | string | ProofLike[], config?: ReceiveConfig, outputType?: OutputType, ): Promise { @@ -885,7 +888,7 @@ class Wallet { * @returns SwapPreview with metadata for swap transaction. */ async prepareSwapToReceive( - token: Token | string | Proof[], + token: Token | string | ProofLike[], config?: ReceiveConfig, outputType?: OutputType, ): Promise { @@ -895,7 +898,7 @@ class Wallet { // Extract proofs — either directly or by decoding the token let proofs: Proof[]; if (Array.isArray(token)) { - proofs = token; + proofs = normalizeProofAmounts(token); } else { const decodedToken: Token = typeof token === 'string' ? this.decodeToken(token) : token; const tokenMintUrl = sanitizeUrl(decodedToken.mint); @@ -907,7 +910,8 @@ class Wallet { token: decodedToken.unit, wallet: this._unit, }); - ({ proofs } = decodedToken); + // Token object may come from JSON.parse/localStorage and need runtime rehydration. + proofs = normalizeProofAmounts(decodedToken.proofs); } // Validate all proof keyset IDs use this wallet's unit @@ -955,7 +959,10 @@ class Wallet { if (autoCounters.used) { this.safeCallback(onCountersReserved, autoCounters.used, { op: 'receive' }); } - this._logger.debug('receive counter', { counter: autoCounters.used, receiveOT }); + this._logger.debug('receive counter', { + counter: autoCounters.used, + receiveOT: stringifyOutputTypeForLog(receiveOT), + }); // Create outputs and execute swap const outputs = this.createOutputData(this.preparedTotal(receiveOT), keyset, receiveOT); @@ -963,7 +970,7 @@ class Wallet { // Return SwapPreview return { amount: receiveAmount, - fees: Amount.from(swapFee), + fees: swapFee, keysetId: keyset.id, inputs: proofs, keepOutputs: outputs, @@ -983,16 +990,25 @@ class Wallet { * @returns SendResponse with keep/send proofs. * @throws Throws if the send cannot be completed offline. */ - sendOffline(amount: AmountLike, proofs: Proof[], config?: SendOfflineConfig): SendResponse { + sendOffline(amount: AmountLike, proofs: ProofLike[], config?: SendOfflineConfig): SendResponse { const sendAmount = this.parseAmount(amount, 'sendOffline'); + let normalizedProofs = normalizeProofAmounts(proofs); const { requireDleq = false, includeFees = false, exactMatch = true } = config || {}; if (requireDleq) { // Only use proofs that have a DLEQ - proofs = proofs.filter((p: Proof) => p.dleq != undefined); + normalizedProofs = normalizedProofs.filter((p) => p.dleq != undefined); } - this.failIf(sumProofs(proofs).lessThan(sendAmount), 'Not enough funds available to send'); + this.failIf( + sumProofs(normalizedProofs).lessThan(sendAmount), + 'Not enough funds available to send', + ); - const { keep, send } = this.selectProofsToSend(proofs, sendAmount, includeFees, exactMatch); + const { keep, send } = this.selectProofsToSend( + normalizedProofs, + sendAmount, + includeFees, + exactMatch, + ); // Ensure witnesses are serialized, strip DLEQ if not required const sendPrepared = this._prepareInputsForMint(send, requireDleq); return { keep, send: sendPrepared }; @@ -1026,7 +1042,7 @@ class Wallet { */ async send( amount: AmountLike, - proofs: Proof[], + proofs: ProofLike[], config?: SendConfig, outputConfig?: OutputConfig, ): Promise { @@ -1111,11 +1127,12 @@ class Wallet { */ async prepareSwapToSend( amount: AmountLike, - proofs: Proof[], + proofs: ProofLike[], config?: SendConfig, outputConfig?: OutputConfig, ): Promise { const sendAmountTarget = this.parseAmount(amount, 'prepareSwapToSend'); + const normalizedProofs = normalizeProofAmounts(proofs); const { keysetId, includeFees = false, onCountersReserved } = config || {}; // Fallback to policy defaults if no outputConfig @@ -1138,13 +1155,13 @@ class Wallet { // Select the subset of proofs needed to cover the swap (sendTarget + swap fee) const { keep: unselectedProofs, send: selectedProofs } = this.selectProofsToSend( - proofs, + normalizedProofs, sendAmount, true, // Include fees to cover swap fee ); // this._logger.debug('PROOFS SELECTED', { - // unselectedProofs: unselectedProofs.map(p=>p.amount), - // selectedProofs: selectedProofs.map(p=>p.amount), + // unselectedProofs: unselectedProofs.map(p=>p.amount.toString()), + // selectedProofs: selectedProofs.map(p=>p.amount.toString()), // }); if (selectedProofs.length === 0) { throw new Error('Not enough funds available to send'); @@ -1180,7 +1197,11 @@ class Wallet { if (autoCounters.used) { this.safeCallback(onCountersReserved, autoCounters.used, { op: 'send' }); } - this._logger.debug('send counters', { counter: autoCounters.used, sendOT, keepOT }); + this._logger.debug('send counters', { + counter: autoCounters.used, + sendOT: stringifyOutputTypeForLog(sendOT), + keepOT: stringifyOutputTypeForLog(keepOT), + }); // Create the output data const sendOutputs = this.createOutputData(sendAmount, keyset, sendOT); @@ -1189,7 +1210,7 @@ class Wallet { // Return SwapPreview return { amount: sendAmountTarget, - fees: Amount.from(swapFee), + fees: swapFee, keysetId: keyset.id, inputs: selectedProofs, sendOutputs, @@ -1270,9 +1291,9 @@ class Wallet { } }); this._logger.debug('SEND COMPLETED', { - unselectedProofs: unselectedProofs.map((p) => p.amount), - keepProofs: keepProofs.map((p) => p.amount), - sendProofs: sendProofs.map((p) => p.amount), + unselectedProofs: unselectedProofs.map((p) => p.amount.toString()), + keepProofs: keepProofs.map((p) => p.amount.toString()), + sendProofs: sendProofs.map((p) => p.amount.toString()), }); return { keep: [...keepProofs, ...unselectedProofs], @@ -1328,27 +1349,28 @@ class Wallet { * @returns Signed proofs. */ signP2PKProofs( - proofs: Proof[], + proofs: ProofLike[], privkey: string | string[], outputData?: OutputDataLike[], quoteId?: string, ): Proof[] { + const normalizedProofs = normalizeProofAmounts(proofs); // Normal case, sign everything as usual - if (!isP2PKSigAll(proofs)) { - return cryptoSignP2PKProofs(proofs, privkey, this._logger); + if (!isP2PKSigAll(normalizedProofs)) { + return cryptoSignP2PKProofs(normalizedProofs, privkey, this._logger); } // Ensure SIG_ALL conditions are met this.failIfNullish(outputData, 'OutputData is required for SIG_ALL proof signing.'); - assertSigAllInputs(proofs); + assertSigAllInputs(normalizedProofs); // SIG_ALL is in flux currently, so let's generate all known message formats // and sign the first proof only against each message... - const [first, ...rest] = proofs; + const [first, ...rest] = normalizedProofs; let signedFirst = first; const messages = [ - buildLegacyP2PKSigAllMessage(proofs, outputData, quoteId), - buildP2PKSigAllMessage(proofs, outputData, quoteId), + buildLegacyP2PKSigAllMessage(normalizedProofs, outputData, quoteId), + buildP2PKSigAllMessage(normalizedProofs, outputData, quoteId), ]; for (const msg of messages) { signedFirst = cryptoSignP2PKProofs([signedFirst], privkey, this._logger, msg)[0]; @@ -1537,7 +1559,7 @@ class Wallet { const matchingSig = signatureMap[outputData[i].blindedMessage.B_]; if (matchingSig) { lastCounterWithSignature = start + i; - outputData[i].blindedMessage.amount = matchingSig.amount.toBigInt(); + outputData[i].blindedMessage.amount = matchingSig.amount; restoredProofs.push(outputData[i].toProof(matchingSig, keyset)); } } @@ -1901,7 +1923,10 @@ class Wallet { if (autoCounters.used) { this.safeCallback(onCountersReserved, autoCounters.used, { op: 'mintProofs' }); } - this._logger.debug('mint counter', { counter: autoCounters.used, mintOT }); + this._logger.debug('mint counter', { + counter: autoCounters.used, + mintOT: stringifyOutputTypeForLog(mintOT), + }); // Create outputs and mint payload const outputs = this.createOutputData(mintAmount, keyset, mintOT); @@ -1962,7 +1987,7 @@ class Wallet { const keyset = this.getKeyset(keysetId); this._logger.debug('MINT COMPLETED', { - amounts: outputData.map((o) => o.blindedMessage.amount), + amounts: outputData.map((o) => o.blindedMessage.amount.toString()), }); // Verify each signature amount matches the requested amount for (let i = 0; i < signatures.length; i++) { @@ -1984,8 +2009,6 @@ class Wallet { * * - Any quote without a pubkey is considered unlocked. Pass `pubkey` for locked quotes. * - Check all quotes are in the PAID state. If any quote is unpaid, the entire batch with fail. - * - `BatchMintPreview` contains `bigint` values. Use `JSONInt.stringify` to serialize (not - * `JSON.stringify`). * * @param method Payment method identifier (e.g., 'bolt11', 'bolt12'). * @param entries Array of per-quote parameters: `{ amount, quote }`. @@ -2368,7 +2391,7 @@ class Wallet { async meltProofs>( method: string, meltQuote: TQuote, - proofsToSend: Proof[], + proofsToSend: ProofLike[], config?: MeltProofsConfig, outputType?: OutputType, ): Promise> { @@ -2390,7 +2413,7 @@ class Wallet { */ async meltProofsBolt11( meltQuote: MeltQuoteBolt11Response, - proofsToSend: Proof[], + proofsToSend: ProofLike[], config?: MeltProofsConfig, outputType?: OutputType, ): Promise> { @@ -2412,7 +2435,7 @@ class Wallet { */ async meltProofsBolt12( meltQuote: MeltQuoteBolt12Response, - proofsToSend: Proof[], + proofsToSend: ProofLike[], config?: MeltProofsConfig, outputType?: OutputType, ): Promise> { @@ -2440,15 +2463,16 @@ class Wallet { async prepareMelt>( method: string, meltQuote: TQuote, - proofsToSend: Proof[], + proofsToSend: ProofLike[], config?: MeltProofsConfig, outputType?: OutputType, ): Promise> { this.validateMeltQuote(meltQuote); + const normalizedProofs = normalizeProofAmounts(proofsToSend); outputType = outputType ?? this.defaultOutputType(); // Fallback to policy const { keysetId, onCountersReserved } = config || {}; const keyset = this.getKeyset(keysetId); // specified or wallet keyset - const sendAmount = sumProofs(proofsToSend); + const sendAmount = sumProofs(normalizedProofs); // feeReserve is the overage above the invoice/offer amount. // In the common case where selected proofs = amount + fee_reserve, @@ -2487,7 +2511,10 @@ class Wallet { if (autoCounters.used) { this.safeCallback(onCountersReserved, autoCounters.used, { op: 'meltProofs' }); } - this._logger.debug('melt counter', { counter: autoCounters.used, meltOT }); + this._logger.debug('melt counter', { + counter: autoCounters.used, + meltOT: stringifyOutputTypeForLog(meltOT), + }); // Generate the blank outputs (no fees as we are receiving change) // Remember, zero amount + zero denomination passes splitAmount validation outputData = this.createOutputData(0, keyset, meltOT); @@ -2496,7 +2523,7 @@ class Wallet { // Create melt preview const meltPreview: MeltPreview = { method, - inputs: proofsToSend, + inputs: normalizedProofs, outputData, keysetId: keyset.id, quote: meltQuote, diff --git a/src/wallet/WalletOps.ts b/src/wallet/WalletOps.ts index 6bb9c5653..ec672d9e9 100644 --- a/src/wallet/WalletOps.ts +++ b/src/wallet/WalletOps.ts @@ -8,7 +8,7 @@ import { type MintQuoteBolt12Response, type MintQuoteBolt11Response, } from '../model/types'; -import type { Proof } from '../model/types/proof'; +import type { ProofLike } from '../model/types/proof'; import type { Token } from '../model/types/token'; import { @@ -40,10 +40,10 @@ export type MintQuoteFor = M extends 'bolt11' */ export class WalletOps { constructor(private wallet: Wallet) {} - send(amount: AmountLike, proofs: Proof[]) { + send(amount: AmountLike, proofs: ProofLike[]) { return new SendBuilder(this.wallet, amount, proofs); } - receive(token: Token | string | Proof[]) { + receive(token: Token | string | ProofLike[]) { return new ReceiveBuilder(this.wallet, token); } /** @@ -57,10 +57,10 @@ export class WalletOps { mintBolt12(amount: AmountLike, quote: MintQuoteFor<'bolt12'>) { return new MintBuilder<'bolt12'>(this.wallet, 'bolt12', amount, quote); } - meltBolt11(quote: MeltQuoteBolt11Response, proofs: Proof[]) { + meltBolt11(quote: MeltQuoteBolt11Response, proofs: ProofLike[]) { return new MeltBuilder(this.wallet, 'bolt11', quote, proofs); } - meltBolt12(quote: MeltQuoteBolt12Response, proofs: Proof[]) { + meltBolt12(quote: MeltQuoteBolt12Response, proofs: ProofLike[]) { return new MeltBuilder(this.wallet, 'bolt12', quote, proofs); } } @@ -91,7 +91,7 @@ export class SendBuilder { constructor( private wallet: Wallet, amount: AmountLike, - private proofs: Proof[], + private proofs: ProofLike[], ) { this.amount = Amount.from(amount); } @@ -240,7 +240,7 @@ export class SendBuilder { * Has no effect if denominations (custom split) was specified. * @param p Proofs currently held by the wallet, used to hit denomination targets. */ - proofsWeHave(p: Array>) { + proofsWeHave(p: Array>) { this.config.proofsWeHave = p; return this; } @@ -362,7 +362,7 @@ export class ReceiveBuilder { constructor( private wallet: Wallet, - private token: Token | string | Proof[], + private token: Token | string | ProofLike[], ) {} /** @@ -463,7 +463,7 @@ export class ReceiveBuilder { * Has no effect if denominations (custom split) was specified. * @param p Proofs currently held by the wallet, used to hit denomination targets. */ - proofsWeHave(p: Array>) { + proofsWeHave(p: Array>) { this.config.proofsWeHave = p; return this; } @@ -628,7 +628,7 @@ export class MintBuilder< * Has no effect if denominations (custom split) was specified. * @param p Proofs currently held by the wallet, used to hit denomination targets. */ - proofsWeHave(p: Array>) { + proofsWeHave(p: Array>) { this.config.proofsWeHave = p; return this; } @@ -741,7 +741,7 @@ export class MeltBuilder< private wallet: Wallet, private method: string, private quote: TQuote, - private proofs: Proof[], + private proofs: ProofLike[], ) {} /** diff --git a/src/wallet/_internal.ts b/src/wallet/_internal.ts index 05f64ccc4..358d66012 100644 --- a/src/wallet/_internal.ts +++ b/src/wallet/_internal.ts @@ -5,6 +5,8 @@ import { Amount, type AmountLike } from '../model/Amount'; import type { Keys, Proof } from '../model/types'; import { splitAmount } from '../utils/core'; +import { type OutputType } from './types'; + function getKeysetAmountsAsc(keys: Keys): Amount[] { const amounts = Object.keys(keys).map((k) => Amount.from(k)); amounts.sort((a, b) => a.compareTo(b)); @@ -51,3 +53,41 @@ export function getKeepAmounts( } return amountsWeWant.sort((a, b) => a.compareTo(b)); } + +/** + * Helper to properly format OutputTypes for logs. + */ +export function stringifyOutputTypeForLog(ot: OutputType): string { + switch (ot.type) { + case 'custom': + return JSON.stringify({ + type: 'custom', + outputs: ot.data.length, + amounts: ot.data.map((d) => d.blindedMessage.amount.toString()), + }); + case 'factory': + return JSON.stringify({ + type: 'factory', + denominations: (ot.denominations ?? []).map((d) => Amount.from(d).toString()), + }); + case 'deterministic': + return JSON.stringify({ + type: 'deterministic', + counter: ot.counter, + denominations: (ot.denominations ?? []).map((d) => Amount.from(d).toString()), + }); + case 'p2pk': + return JSON.stringify({ + type: 'p2pk', + options: ot.options, + denominations: (ot.denominations ?? []).map((d) => Amount.from(d).toString()), + }); + case 'random': + return JSON.stringify({ + type: 'random', + denominations: (ot.denominations ?? []).map((d) => Amount.from(d).toString()), + }); + default: + return 'Unknown'; + } +} diff --git a/src/wallet/types/config.ts b/src/wallet/types/config.ts index b41c28a71..b4adccf5f 100644 --- a/src/wallet/types/config.ts +++ b/src/wallet/types/config.ts @@ -1,7 +1,7 @@ import { type P2PKOptions } from '../../crypto'; import { type AmountLike } from '../../model/Amount'; import { type OutputDataFactory, type OutputDataLike } from '../../model/OutputData'; -import type { Proof } from '../../model/types/proof'; +import type { ProofLike } from '../../model/types/proof'; import { type OperationCounters } from '../CounterSource'; export type SecretsPolicy = 'auto' | 'deterministic' | 'random'; @@ -111,7 +111,7 @@ export type SendConfig = { keysetId?: string; privkey?: string | string[]; includeFees?: boolean; - proofsWeHave?: Array>; + proofsWeHave?: Array>; onCountersReserved?: OnCountersReserved; }; @@ -131,7 +131,7 @@ export type ReceiveConfig = { keysetId?: string; privkey?: string | string[]; requireDleq?: boolean; - proofsWeHave?: Array>; + proofsWeHave?: Array>; onCountersReserved?: OnCountersReserved; }; @@ -141,7 +141,7 @@ export type ReceiveConfig = { export type MintProofsConfig = { keysetId?: string; privkey?: string | string[]; - proofsWeHave?: Array>; + proofsWeHave?: Array>; onCountersReserved?: OnCountersReserved; }; diff --git a/src/wallet/types/payloads.ts b/src/wallet/types/payloads.ts index e5bc0b1a3..57d29792e 100644 --- a/src/wallet/types/payloads.ts +++ b/src/wallet/types/payloads.ts @@ -1,4 +1,4 @@ -import { type AmountLike } from '../../model/Amount'; +import { type Amount } from '../../model/Amount'; import { type OutputDataLike } from '../../model/OutputData'; import { type MeltQuoteBaseResponse, @@ -13,7 +13,7 @@ import { type Proof } from '../../model/types/proof'; * Preview of a mint transaction created by prepareMint. * * @remarks - * Contains `bigint` values. Use `JSONInt.stringify` + * Contains JSON-unsafe values (`bigint`, `Uint8Array`). Not intended for direct serialization. */ export interface MintPreview< TQuote extends Pick = MintQuoteBaseResponse, @@ -41,7 +41,7 @@ export interface MintPreview< * Preview of a batched mint transaction created by prepareBatchMint. * * @remarks - * Contains `bigint` values. Use `JSONInt.stringify` + * Contains JSON-unsafe values (`bigint`, `Uint8Array`). Not intended for direct serialization. */ export interface BatchMintPreview< TQuote extends Pick = MintQuoteBaseResponse, @@ -69,7 +69,7 @@ export interface BatchMintPreview< * Preview of a Melt transaction created by prepareMelt. * * @remarks - * Contains `bigint` values. Use `JSONInt.stringify` + * Contains JSON-unsafe values (`bigint`, `Uint8Array`). Not intended for direct serialization. */ export interface MeltPreview< TQuote extends Pick = MeltQuoteBaseResponse, @@ -97,7 +97,7 @@ export interface MeltPreview< * Includes all data required to swap inputs for outputs and construct proofs from them. * * @remarks - * Contains `bigint` values. Use `JSONInt.stringify` + * Contains JSON-unsafe values (`bigint`, `Uint8Array`). Not intended for direct serialization. */ export type SwapTransaction = { /** @@ -122,17 +122,17 @@ export type SwapTransaction = { * Preview of a swap transaction created by prepareSend / prepareReceive. * * @remarks - * Contains `bigint` values. Use `JSONInt.stringify` + * Contains JSON-unsafe values (`bigint`, `Uint8Array`). Not intended for direct serialization. */ export type SwapPreview = { /** * Amount being sent or received (excluding fees). */ - amount: AmountLike; + amount: Amount; /** * Total fees for the swap (inc receiver's fees if applicable) */ - fees: AmountLike; + fees: Amount; /** * Keyset ID used to prepare the outputs. */ diff --git a/test/auth/AuthManager.node.test.ts b/test/auth/AuthManager.node.test.ts index 53184c88f..d070813c9 100644 --- a/test/auth/AuthManager.node.test.ts +++ b/test/auth/AuthManager.node.test.ts @@ -35,7 +35,7 @@ import type { Proof } from '../../src/model/types'; import * as utils from '../../src/utils'; import { encodeBase64toUint8, Bytes } from '../../src/utils'; import { OutputData } from '../../src/model/OutputData'; -import { RequestFn } from '../../src'; +import { Amount, RequestFn } from '../../src'; import type { Logger } from '../../src/logger'; const mintUrl = 'http://mint.local'; @@ -537,8 +537,20 @@ describe('getBlindAuthToken coverage', () => { test('importPool dedupes by secret and exportPool deep-copies and preserves missing dleq', () => { const am = new AuthManager(mintUrl, { request: reqSpy as RequestFn }); - const a: Proof = { id: 'k', C: 'C1', secret: 'S', dleq: { e: 'e1', s: 's1' }, amount: 1n }; - const b: Proof = { id: 'k', C: 'C2', secret: 'S', dleq: { e: 'e2', s: 's2' }, amount: 1n }; // dup secret + const a: Proof = { + id: 'k', + C: 'C1', + secret: 'S', + dleq: { e: 'e1', s: 's1' }, + amount: Amount.from(1), + }; + const b: Proof = { + id: 'k', + C: 'C2', + secret: 'S', + dleq: { e: 'e2', s: 's2' }, + amount: Amount.from(1), + }; // dup secret const c: Proof = { id: 'k', C: 'C3', secret: 'T', amount: 1 } as any; // no dleq am.importPool([a, b, c], 'replace'); diff --git a/test/crypto/NUT10.test.ts b/test/crypto/NUT10.test.ts index d76e55010..6a21b3c95 100644 --- a/test/crypto/NUT10.test.ts +++ b/test/crypto/NUT10.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from 'vitest'; import { getTagInt, hasTag, parseHTLCSecret, parseSecret } from '../../src/crypto'; -import { Proof } from '../../src'; +import { Amount, Proof } from '../../src'; const proof: Proof = { - amount: 2n, + amount: Amount.from(2), id: '00bfa73302d12ffd', secret: '["HTLC",{"nonce":"c9b0fabb8007c0db4bef64d5d128cdcf3c79e8bb780c3294adf4c88e96c32647","data":"ec4916dd28fc4c10d78e287ca5d9cc51ee1ae73cbfde08c6b37324cbfaac8bc5","tags":[["pubkeys","039e6ec7e922abb4162235b3a42965eb11510b07b7461f6b1a17478b1c9c64d100"],["locktime","1"],["refund","02ce1bbd2c9a4be8029c9a6435ad601c45677f5cde81f8a7f0ed535e0039d0eb6c","03c43c00ff57f63cfa9e732f0520c342123e21331d0121139f1b636921eeec095f"],["n_sigs_refund","2"],["sigflag","SIG_ALL"]]}]', diff --git a/test/crypto/NUT11.test.ts b/test/crypto/NUT11.test.ts index 3c2807249..b680f5ddd 100644 --- a/test/crypto/NUT11.test.ts +++ b/test/crypto/NUT11.test.ts @@ -28,7 +28,7 @@ import { } from '../../src/crypto'; import { Proof, P2PKWitness } from '../../src/model/types'; import { sha256 } from '@noble/hashes/sha2.js'; -import { OutputDataLike } from '../../src'; +import { Amount, OutputDataLike } from '../../src'; import { ConsoleLogger, NULL_LOGGER } from '../../src/logger'; const PRIVKEY = schnorr.utils.randomSecretKey(); @@ -52,7 +52,7 @@ describe('test create p2pk secret', () => { test('sign and verify proof', async () => { const secretStr = `["P2PK",{"nonce":"76f5bf3e36273bf1a09006ef32d4551c07a34e218c2fc84958425ad00abdfe06","data":"${PUBKEY}"}]`; const proof: Proof = { - amount: 1n, + amount: Amount.from(1), C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', id: '00000000000', secret: secretStr, @@ -65,14 +65,14 @@ describe('test create p2pk secret', () => { test('sign and verify proofs', async () => { const secretStr = `["P2PK",{"nonce":"76f5bf3e36273bf1a09006ef32d4551c07a34e218c2fc84958425ad00abdfe06","data":"${PUBKEY}"}]`; const proof1: Proof = { - amount: 1n, + amount: Amount.from(1), C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', id: '00000000000', secret: secretStr, }; const proof2: Proof = { - amount: 1n, + amount: Amount.from(1), C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', id: '00000000000', secret: secretStr, @@ -94,14 +94,14 @@ describe('test create p2pk secret', () => { const secretStr = `["P2PK",{"nonce":"76f5bf3e36273bf1a09006ef32d4551c07a34e218c2fc84958425ad00abdfe06","data":"${PUBKEY}"}]`; const secretStr2 = `["P2PK",{"nonce":"76f5bf3e36273bf1a09006ef32d4551c07a34e218c2fc84958425ad00abdfe06","data":"${PUBKEY2}"}]`; const proof1: Proof = { - amount: 1n, + amount: Amount.from(1), C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', id: '00000000000', secret: secretStr, }; const proof2: Proof = { - amount: 1n, + amount: Amount.from(1), C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', id: '00000000000', secret: secretStr2, @@ -123,14 +123,14 @@ describe('test create p2pk secret', () => { const secretStr = `["P2PK",{"nonce":"76f5bf3e36273bf1a09006ef32d4551c07a34e218c2fc84958425ad00abdfe06","data":"${PUBKEY}","tags":[["n_sigs","2"],["pubkeys","${PUBKEY2}"]]}]`; const secretStr2 = `["P2PK",{"nonce":"76f5bf3e36273bf1a09006ef32d4551c07a34e218c2fc84958425ad00abdfe06","data":"${PUBKEY}","tags":[["n_sigs","1"],["pubkeys","${PUBKEY2}"]]}]`; const proof1: Proof = { - amount: 1n, + amount: Amount.from(1), C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', id: '00000000000', secret: secretStr, }; const proof2: Proof = { - amount: 1n, + amount: Amount.from(1), C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', id: '00000000000', secret: secretStr2, @@ -152,14 +152,14 @@ describe('test create p2pk secret', () => { const secretStr = `["P2PK",{"nonce":"76f5bf3e36273bf1a09006ef32d4551c07a34e218c2fc84958425ad00abdfe06","data":"${PUBKEY}","tags":[["n_sigs","2"],["pubkeys","${PUBKEY2}"]]}]`; const secretStr2 = `["P2PK",{"nonce":"76f5bf3e36273bf1a09006ef32d4551c07a34e218c2fc84958425ad00abdfe06","data":"${PUBKEY2}","tags":[["n_sigs","1"]]}]`; const proof1: Proof = { - amount: 1n, + amount: Amount.from(1), C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', id: '00000000000', secret: secretStr, }; const proof2: Proof = { - amount: 1n, + amount: Amount.from(1), C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', id: '00000000000', secret: secretStr2, @@ -182,7 +182,7 @@ describe('test create p2pk secret', () => { const secretStr = `["P2PK",{"nonce":"76f5bf3e36273bf1a09006ef32d4551c07a34e218c2fc84958425ad00abdfe06","data":"${PUBKEY}","tags":[["n_sigs","2"],["locktime","212"],["pubkeys","${PUBKEY2}"],["refund","${PUBKEY2}","${PUBKEY3}"]]}]`; const proof: Proof = { - amount: 1n, + amount: Amount.from(1), C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', id: '00000000000', secret: secretStr, @@ -195,7 +195,7 @@ describe('test create p2pk secret', () => { test('verify unlocked proofs and bad witness', async () => { const secretStr = `["P2PK",{"nonce":"76f5bf3e36273bf1a09006ef32d4551c07a34e218c2fc84958425ad00abdfe06","data":"${PUBKEY}","tags":[["locktime","123"]]}]`; const proof1: Proof = { - amount: 1n, + amount: Amount.from(1), C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', id: '00000000000', secret: secretStr, @@ -259,7 +259,7 @@ describe('test getP2PKSigFlag', () => { describe('verifyP2PKSpendingConditions metadata', () => { test('non-p2pk secret', async () => { const proof: Proof = { - amount: 1n, + amount: Amount.from(1), C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', id: '00000000000', secret: `["BAD",{"nonce":"76f5bf3e36273bf1a09006ef32d4551c07a34e218c2fc84958425ad00abdfe06","data":"${PUBKEY}"}]`, @@ -271,7 +271,7 @@ describe('verifyP2PKSpendingConditions metadata', () => { const PRIVKEY2 = schnorr.utils.randomSecretKey(); const PUBKEY2 = bytesToHex(getPubKeyFromPrivKey(PRIVKEY2)); const proof: Proof = { - amount: 1n, + amount: Amount.from(1), C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', id: '00000000000', secret: `["P2PK",{"nonce":"76f5bf3e36273bf1a09006ef32d4551c07a34e218c2fc84958425ad00abdfe06","data":"${PUBKEY}","tags":[["pubkeys","${PUBKEY2}"]]}]`, @@ -292,7 +292,7 @@ describe('verifyP2PKSpendingConditions metadata', () => { const PRIVKEY2 = schnorr.utils.randomSecretKey(); const PUBKEY2 = bytesToHex(getPubKeyFromPrivKey(PRIVKEY2)); const proof: Proof = { - amount: 1n, + amount: Amount.from(1), C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', id: '00000000000', secret: `["P2PK",{"nonce":"76f5bf3e36273bf1a09006ef32d4551c07a34e218c2fc84958425ad00abdfe06","data":"${PUBKEY}","tags":[["n_sigs","2"],["locktime","212"],["pubkeys","${PUBKEY2}"]]}]`, @@ -315,7 +315,7 @@ describe('verifyP2PKSpendingConditions metadata', () => { const PRIVKEY3 = schnorr.utils.randomSecretKey(); const PUBKEY3 = bytesToHex(getPubKeyFromPrivKey(PRIVKEY3)); const proof: Proof = { - amount: 1n, + amount: Amount.from(1), C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', id: '00000000000', secret: `["P2PK",{"nonce":"76f5bf3e36273bf1a09006ef32d4551c07a34e218c2fc84958425ad00abdfe06","data":"${PUBKEY}","tags":[["n_sigs","2"],["locktime","212"],["pubkeys","${PUBKEY2}"],["refund","${PUBKEY2}","${PUBKEY3}"]]}]`, @@ -388,7 +388,7 @@ describe('test signP2PKProof', () => { test('non-p2pk secret', async () => { const secretStr = `["BAD",{"nonce":"76f5bf3e36273bf1a09006ef32d4551c07a34e218c2fc84958425ad00abdfe06","data":"${PUBKEY}"}]`; const proof: Proof = { - amount: 1n, + amount: Amount.from(1), C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', id: '00000000000', secret: secretStr, @@ -398,7 +398,7 @@ describe('test signP2PKProof', () => { test('can only sign and verify once', async () => { const secretStr = `["P2PK",{"nonce":"76f5bf3e36273bf1a09006ef32d4551c07a34e218c2fc84958425ad00abdfe06","data":"${PUBKEY}"}]`; const proof: Proof = { - amount: 1n, + amount: Amount.from(1), C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', id: '00000000000', secret: secretStr, @@ -421,7 +421,7 @@ describe('test signP2PKProof', () => { const secretStr = `["P2PK",{"nonce":"76f5bf3e36273bf1a09006ef32d4551c07a34e218c2fc84958425ad00abdfe06","data":"${PUBKEY}"}]`; const proof: Proof = { - amount: 1n, + amount: Amount.from(1), C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', id: '00000000000', secret: secretStr, @@ -439,7 +439,7 @@ describe('test signP2PKProof', () => { expect(PUBKEY3).toMatch(/^03/); // Verify it really is an odd Y-parity key const secretStr = `["P2PK",{"nonce":"76f5bf3e36273bf1a09006ef32d4551c07a34e218c2fc84958425ad00abdfe06","data":"${PUBKEY2}"}]`; const proof: Proof = { - amount: 1n, + amount: Amount.from(1), C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', id: '00000000000', secret: secretStr, @@ -496,7 +496,7 @@ describe('test getP2PKWitnessSignatures', () => { describe('test p2pk verify', () => { test('test no witness', () => { const proof: Proof = { - amount: 1n, + amount: Amount.from(1), id: '00000000', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', secret: `["P2PK",{"nonce":"76f5bf3e36273bf1a09006ef32d4551c07a34e218c2fc84958425ad00abdfe06","data":"${bytesToHex( @@ -540,7 +540,7 @@ describe('P2BK fixed-vector ECDH tweak', () => { // Now exercise signing end-to-end const proof = { - amount: 64n, + amount: Amount.from(64), id: idHex, C: '03657c5d884350d232dd219cfd68b1f19bc844c324d08d66e1f1db64106410de39', // any point is fine here secret: secretStr, @@ -571,7 +571,13 @@ describe('P2BK roundtrips (deriveP2BKBlindedPubkeys in secret -> signP2PKProofs) const secret = createP2PKsecret(P0_, []); // Proof-like object handed to signer - const proof = { amount: 1n, id: kidHex, C: '03'.padEnd(66, '0'), secret, p2pk_e: E } as any; + const proof = { + amount: Amount.from(1), + id: kidHex, + C: '03'.padEnd(66, '0'), + secret, + p2pk_e: E, + } as any; // Bob signs using derived k; verify succeeds and witness is present const [signed] = signP2PKProofs([proof], pBob); @@ -592,7 +598,13 @@ describe('P2BK roundtrips (deriveP2BKBlindedPubkeys in secret -> signP2PKProofs) ['n_sigs', '2'], ]); // Proof-like object handed to signer - const base = { amount: 1n, id: kidHex, C: '03'.padEnd(66, '0'), secret, p2pk_e: E } as any; + const base = { + amount: Amount.from(1), + id: kidHex, + C: '03'.padEnd(66, '0'), + secret, + p2pk_e: E, + } as any; // Only Alice signs -> insufficient (witness added, but verify = false) const [oneSigned] = signP2PKProofs([structuredClone(base)], pAlice); @@ -703,7 +715,7 @@ describe('schnorrVerifyMessage & hasP2PKSignedProof', () => { const sig = schnorrSignMessage(secret, priv); const proofWithMatch: Proof = { - amount: 1n, + amount: Amount.from(1), id: 'a', C: '03'.padEnd(66, '0'), secret, @@ -715,12 +727,12 @@ describe('schnorrVerifyMessage & hasP2PKSignedProof', () => { const otherPub = bytesToHex(getPubKeyFromPrivKey(otherPriv)); expect(hasP2PKSignedProof(otherPub, proofWithMatch)).toBe(false); - const noWitness: Proof = { amount: 1n, id: 'b', C: '03'.padEnd(66, '0'), secret }; + const noWitness: Proof = { amount: Amount.from(1), id: 'b', C: '03'.padEnd(66, '0'), secret }; expect(hasP2PKSignedProof(pub, noWitness)).toBe(false); }); test('returns false with non json witness string', () => { const proof: Proof = { - amount: 1n, + amount: Amount.from(1), id: 'mw', C: '03'.padEnd(66, '0'), secret: `["P2PK",{"nonce":"aa","data":"${PUBKEY}"}]`, @@ -730,7 +742,7 @@ describe('schnorrVerifyMessage & hasP2PKSignedProof', () => { }); test('throws on SIG_ALL secret', () => { const proof: Proof = { - amount: 1n, + amount: Amount.from(1), id: 'mw', C: '03'.padEnd(66, '0'), secret: `["P2PK",{"nonce":"aa","data":"${PUBKEY}","tags":[["sigflag","SIG_ALL"]]}]`, @@ -970,13 +982,13 @@ describe('SIG_ALL, both message formats are actually signed', () => { // 2. Build a minimal SIG_ALL input set that passes assertSigAllInputs const proofs: Proof[] = [ { - amount: 1n, + amount: Amount.from(1), id: '00a1', C: '03'.padEnd(66, '1'), secret, } as Proof, { - amount: 2n, + amount: Amount.from(2), id: '00a2', C: '03'.padEnd(66, '2'), secret, @@ -1038,7 +1050,7 @@ describe('NUT-11 test vectors', () => { test('Valid Locktime Multisig', async () => { const proof: Proof = { - amount: 64n, + amount: Amount.from(64), C: '02d7cd858d866fca404b5cb1ffd813946e6d19efa1af00d654080fd20266bdc0b1', id: '001b6c716bf42c7e', secret: @@ -1051,7 +1063,7 @@ describe('NUT-11 test vectors', () => { test('Valid Refund Multisig', async () => { const proof: Proof = { - amount: 64n, + amount: Amount.from(64), C: '02d7cd858d866fca404b5cb1ffd813946e6d19efa1af00d654080fd20266bdc0b1', id: '001b6c716bf42c7e', secret: @@ -1064,7 +1076,7 @@ describe('NUT-11 test vectors', () => { test('SIG_INPUTS - valid signature', async () => { const proof: Proof = { - amount: 1n, + amount: Amount.from(1), secret: '["P2PK",{"nonce":"859d4935c4907062a6297cf4e663e2835d90d97ecdd510745d32f6816323a41f","data":"0249098aa8b9d2fbec49ff8598feb17b592b986e62319a4fa488a3dc36387157a7","tags":[["sigflag","SIG_INPUTS"]]}]', C: '02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904', @@ -1077,7 +1089,7 @@ describe('NUT-11 test vectors', () => { test('SIG_INPUTS - invalid signature', async () => { const proof: Proof = { - amount: 1n, + amount: Amount.from(1), secret: '["P2PK",{"nonce":"859d4935c4907062a6297cf4e663e2835d90d97ecdd510745d32f6816323a41f","data":"0249098aa8b9d2fbec49ff8598feb17b592b986e62319a4fa488a3dc36387157a7","tags":[["sigflag","SIG_INPUTS"]]}]', C: '02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904', @@ -1090,7 +1102,7 @@ describe('NUT-11 test vectors', () => { test('SIG_INPUTS - 2 signatures required to meet multi-signature', async () => { const proof: Proof = { - amount: 1n, + amount: Amount.from(1), secret: '["P2PK",{"nonce":"0ed3fcb22c649dd7bbbdcca36e0c52d4f0187dd3b6a19efcc2bfbebb5f85b2a1","data":"0249098aa8b9d2fbec49ff8598feb17b592b986e62319a4fa488a3dc36387157a7","tags":[["pubkeys","0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","02142715675faf8da1ecc4d51e0b9e539fa0d52fdd96ed60dbe99adb15d6b05ad9"],["n_sigs","2"],["sigflag","SIG_INPUTS"]]}]', C: '02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904', @@ -1103,7 +1115,7 @@ describe('NUT-11 test vectors', () => { test('SIG_INPUTS - one signature failing multi-signature', async () => { const proof: Proof = { - amount: 1n, + amount: Amount.from(1), secret: '["P2PK",{"nonce":"0ed3fcb22c649dd7bbbdcca36e0c52d4f0187dd3b6a19efcc2bfbebb5f85b2a1","data":"0249098aa8b9d2fbec49ff8598feb17b592b986e62319a4fa488a3dc36387157a7","tags":[["pubkeys","0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","02142715675faf8da1ecc4d51e0b9e539fa0d52fdd96ed60dbe99adb15d6b05ad9"],["n_sigs","2"],["sigflag","SIG_INPUTS"]]}]', C: '02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904', @@ -1116,7 +1128,7 @@ describe('NUT-11 test vectors', () => { test('SIG_INPUTS - signature from refund key, spendable because locktime is in the past', async () => { const proof: Proof = { - amount: 64n, + amount: Amount.from(64), C: '0257353051c02e2d650dede3159915c8be123ba4f47cf33183c7fedd20bd91a79b', id: '001b6c716bf42c7e', secret: @@ -1129,7 +1141,7 @@ describe('NUT-11 test vectors', () => { test('SIG_INPUTS - signature from refund key, NOT spendable because locktime is in the future', async () => { const proof: Proof = { - amount: 64n, + amount: Amount.from(64), C: '0215865e3b30bdf6f5cdc1ee2c33379d5629bdf2eff2595603d939ff8c65d80586', id: '001b6c716bf42c7e', secret: @@ -1143,7 +1155,7 @@ describe('NUT-11 test vectors', () => { test('SIG_ALL - SwapRequest valid msg_to_sign', async () => { const proof: Proof[] = [ { - amount: 2n, + amount: Amount.from(2), id: '00bfa73302d12ffd', secret: '["P2PK",{"nonce":"c7f280eb55c1e8564e03db06973e94bc9b666d9e1ca42ad278408fe625950303","data":"030d8acedfe072c9fa449a1efe0817157403fbec460d8e79f957966056e5dd76c1","tags":[["sigflag","SIG_ALL"]]}]', @@ -1163,7 +1175,7 @@ describe('NUT-11 test vectors', () => { test('SIG_ALL - SwapRequest with a valid sig_all signature', async () => { const proof: Proof = { - amount: 2n, + amount: Amount.from(2), id: '00bfa73302d12ffd', secret: '["P2PK",{"nonce":"c7f280eb55c1e8564e03db06973e94bc9b666d9e1ca42ad278408fe625950303","data":"030d8acedfe072c9fa449a1efe0817157403fbec460d8e79f957966056e5dd76c1","tags":[["sigflag","SIG_ALL"]]}]', @@ -1183,7 +1195,7 @@ describe('NUT-11 test vectors', () => { test('SIG_ALL - SwapRequest invalid as the spending conditions are not identical across inputs', async () => { const proofs: Proof[] = [ { - amount: 1n, + amount: Amount.from(1), id: '00bfa73302d12ffd', secret: '["P2PK",{"nonce":"fa6dd3fac9086c153878dec90b9e37163d38ff2ecf8b37db6470e9d185abbbae","data":"033b42b04e659fed13b669f8b16cdaffc3ee5738608810cf97a7631d09bd01399d","tags":[["sigflag","SIG_ALL"]]}]', @@ -1192,7 +1204,7 @@ describe('NUT-11 test vectors', () => { '{"signatures":["27b4d260a1186e3b62a26c0d14ffeab3b9f7c3889e78707b8fd3836b473a00601afbd53a2288ad20a624a8bbe3344453215ea075fc0ce479dd8666fd3d9162cc"]}', }, { - amount: 2n, + amount: Amount.from(2), id: '00bfa73302d12ffd', secret: '["P2PK",{"nonce":"4007b21fc5f5b1d4920bc0a08b158d98fd0fb2b0b0262b57ff53c6c5d6c2ae8c","data":"033b42b04e659fed13b669f8b16cdaffc3ee5738608810cf97a7631d09bd01399d","tags":[["locktime","122222222222222"],["sigflag","SIG_ALL"]]}]', @@ -1213,7 +1225,7 @@ describe('NUT-11 test vectors', () => { test('SIG_ALL - SwapRequest where multiple valid signatures are required and provided', async () => { const proofs: Proof[] = [ { - amount: 2n, + amount: Amount.from(2), id: '00bfa73302d12ffd', secret: '["P2PK",{"nonce":"04bfd885fc982d553711092d037fdceb7320fd8f96b0d4fd6d31a65b83b94272","data":"0275e78025b558dbe6cb8fdd032a2e7613ca14fda5c1f4c4e3427f5077a7bd90e4","tags":[["pubkeys","035163650bbd5ed4be7693f40f340346ba548b941074e9138b67ef6c42755f3449","02817d22a8edc44c4141e192995a7976647c335092199f9e076a170c7336e2f5cc"],["n_sigs","2"],["sigflag","SIG_ALL"]]}]', @@ -1234,7 +1246,7 @@ describe('NUT-11 test vectors', () => { test('SIG_ALL - SwapRequest - locktime has passed and the refund key signatures are valid', async () => { const proofs: Proof[] = [ { - amount: 2n, + amount: Amount.from(2), id: '00bfa73302d12ffd', secret: '["P2PK",{"nonce":"9ea35553beb18d553d0a53120d0175a0991ca6109370338406eed007b26eacd1","data":"02af21e09300af92e7b48c48afdb12e22933738cfb9bba67b27c00c679aae3ec25","tags":[["locktime","1"],["refund","02637c19143c58b2c58bd378400a7b82bdc91d6dedaeb803b28640ef7d28a887ac","0345c7fdf7ec7c8e746cca264bf27509eb4edb9ac421f8fbfab1dec64945a4d797"],["n_sigs_refund","2"],["sigflag","SIG_ALL"]]}]', @@ -1254,7 +1266,7 @@ describe('NUT-11 test vectors', () => { test('SIG_ALL - SwapRequest - with an HTLC also locked to a public key', async () => { const proofs: Proof[] = [ { - amount: 2n, + amount: Amount.from(2), id: '00bfa73302d12ffd', secret: '["HTLC",{"nonce":"d730dd70cd7ec6e687829857de8e70aab2b970712f4dbe288343eca20e63c28c","data":"ec4916dd28fc4c10d78e287ca5d9cc51ee1ae73cbfde08c6b37324cbfaac8bc5","tags":[["pubkeys","0350cda8a1d5257dbd6ba8401a9a27384b9ab699e636e986101172167799469b14"],["sigflag","SIG_ALL"]]}]', @@ -1274,7 +1286,7 @@ describe('NUT-11 test vectors', () => { test('SIG_ALL - SwapRequest - with an HTLC, invalid, locktime not expired, but proof is signed with the refund key', async () => { const proofs: Proof[] = [ { - amount: 2n, + amount: Amount.from(2), id: '00bfa73302d12ffd', secret: '["HTLC",{"nonce":"512c4045f12fdfd6f55059669c189e040c37c1ce2f8be104ed6aec296acce4e9","data":"ec4916dd28fc4c10d78e287ca5d9cc51ee1ae73cbfde08c6b37324cbfaac8bc5","tags":[["pubkeys","03ba83defd31c63f8841d188f0d41b5bb3af1bb3c08d0ba46f8f1d26a4d45e8cad"],["locktime","4854185133"],["refund","032f1008a79c722e93a1b4b853f85f38283f9ef74ee4c5c91293eb1cc3c5e46e34"],["sigflag","SIG_ALL"]]}]', @@ -1295,7 +1307,7 @@ describe('NUT-11 test vectors', () => { test('SIG_ALL - SwapRequest - valid multisig HTLC also locked to locktime and refund keys', async () => { const proofs: Proof[] = [ { - amount: 2n, + amount: Amount.from(2), id: '00bfa73302d12ffd', secret: '["HTLC",{"nonce":"c9b0fabb8007c0db4bef64d5d128cdcf3c79e8bb780c3294adf4c88e96c32647","data":"ec4916dd28fc4c10d78e287ca5d9cc51ee1ae73cbfde08c6b37324cbfaac8bc5","tags":[["pubkeys","039e6ec7e922abb4162235b3a42965eb11510b07b7461f6b1a17478b1c9c64d100"],["locktime","1"],["refund","02ce1bbd2c9a4be8029c9a6435ad601c45677f5cde81f8a7f0ed535e0039d0eb6c","03c43c00ff57f63cfa9e732f0520c342123e21331d0121139f1b636921eeec095f"],["n_sigs_refund","2"],["sigflag","SIG_ALL"]]}]', @@ -1319,7 +1331,7 @@ describe('NUT-11 test vectors', () => { const pub = '029116d32e7da635c8feeb9f1f4559eb3d9b42d400f9d22a64834d89cde0eb6835'; const inputs = [ { - amount: 2n, + amount: Amount.from(2), id: '00bfa73302d12ffd', secret: `[\"P2PK\",{\"nonce\":\"bbf9edf441d17097e39f5095a3313ba24d3055ab8a32f758ff41c10d45c4f3de\",\"data\":\"${pub}\",\"tags\":[[\"sigflag\",\"SIG_ALL\"]]}]`, C: '02a9d461ff36448469dccf828fa143833ae71c689886ac51b62c8d61ddaa10028b', @@ -1340,7 +1352,7 @@ describe('NUT-11 test vectors', () => { test('SIG_ALL - MeltRequest - valid request', async () => { const proofs: Proof[] = [ { - amount: 2n, + amount: Amount.from(2), id: '00bfa73302d12ffd', secret: '["P2PK",{"nonce":"bbf9edf441d17097e39f5095a3313ba24d3055ab8a32f758ff41c10d45c4f3de","data":"029116d32e7da635c8feeb9f1f4559eb3d9b42d400f9d22a64834d89cde0eb6835","tags":[["sigflag","SIG_ALL"]]}]', @@ -1361,7 +1373,7 @@ describe('NUT-11 test vectors', () => { test('SIG_ALL - MeltRequest - valid multisig', async () => { const proofs: Proof[] = [ { - amount: 2n, + amount: Amount.from(2), id: '00bfa73302d12ffd', secret: '["P2PK",{"nonce":"68d7822538740e4f9c9ebf5183ef6c4501c7a9bca4e509ce2e41e1d62e7b8a99","data":"0394e841bd59aeadce16380df6174cb29c9fea83b0b65b226575e6d73cc5a1bd59","tags":[["pubkeys","033d892d7ad2a7d53708b7a5a2af101cbcef69522bd368eacf55fcb4f1b0494058"],["n_sigs","2"],["sigflag","SIG_ALL"]]}]', @@ -1561,7 +1573,7 @@ describe('verifyP2PKSpendingConditions — semantic validation', () => { function makeProof(tags: string[][], data?: string): Proof { return { - amount: 1n, + amount: Amount.from(1), id: '00000000000', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', secret: JSON.stringify([ @@ -1681,7 +1693,7 @@ describe('verifyP2PKSpendingConditions — semantic validation', () => { const attackerPk = makePk(); const victimPk = makePk(); const proof: Proof = { - amount: 1n, + amount: Amount.from(1), id: '00000000000', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', secret: JSON.stringify([ @@ -1709,7 +1721,7 @@ describe('verifyP2PKSpendingConditions signer counts', () => { test('defaults requiredSigners to 1 when n_sigs tag is absent', () => { const proof: Proof = { - amount: 1n, + amount: Amount.from(1), id: '00000000000', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', secret: createP2PKsecret(pk1), @@ -1720,7 +1732,7 @@ describe('verifyP2PKSpendingConditions signer counts', () => { test('uses n_sigs value when it is a valid positive integer', () => { const pk2 = makePk(); const proof: Proof = { - amount: 1n, + amount: Amount.from(1), id: '00000000000', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', secret: JSON.stringify([ diff --git a/test/crypto/NUT12.test.ts b/test/crypto/NUT12.test.ts index a184dc33b..c011185ad 100644 --- a/test/crypto/NUT12.test.ts +++ b/test/crypto/NUT12.test.ts @@ -83,7 +83,7 @@ describe('OutputData.toProof DLEQ verification', () => { keys: { '1': mintPubKey.toHex(true) }, }; const od = new OutputData( - { amount: 1n, B_: blindMsg.B_.toHex(true), id: 'test-keyset' }, + { amount: Amount.from(1), B_: blindMsg.B_.toHex(true), id: 'test-keyset' }, blindMsg.r, blindMsg.secret, ); @@ -99,7 +99,7 @@ describe('OutputData.toProof DLEQ verification', () => { dleq: { s: bytesToHex(dleq.s), e: bytesToHex(dleq.e) }, }; const proof = od.toProof(sig, keyset); - expect(proof.amount).toBe(1n); + expect(proof.amount.equals(Amount.from(1))).toBe(true); expect(proof.dleq).toBeDefined(); }); diff --git a/test/crypto/NUT14.test.ts b/test/crypto/NUT14.test.ts index 81c162ff0..717e5ad74 100644 --- a/test/crypto/NUT14.test.ts +++ b/test/crypto/NUT14.test.ts @@ -9,7 +9,7 @@ import { signP2PKProof, verifyHTLCHash, } from '../../src/crypto'; -import { Proof } from '../../src'; +import { Amount, Proof } from '../../src'; import { schnorr } from '@noble/curves/secp256k1.js'; import { bytesToHex } from '@noble/curves/utils.js'; @@ -59,7 +59,7 @@ describe('NUT14 module core functions', () => { describe('verifyHTLCSpendingConditions and isHTLCSpendAuthorised', () => { test('HTLC main spending pathway', async () => { const proof: Proof = { - amount: 2n, + amount: Amount.from(2), id: '00bfa73302d12ffd', secret: `["HTLC",{"nonce":"d730dd70cd7ec6e687829857de8e70aab2b970712f4dbe288343eca20e63c28c","data":"ec4916dd28fc4c10d78e287ca5d9cc51ee1ae73cbfde08c6b37324cbfaac8bc5","tags":[["pubkeys","${PUBKEY}"]]}]`, C: '03ff6567e2e6c31db5cb7189dab2b5121930086791c93899e4eff3dda61cb57273', @@ -70,7 +70,7 @@ describe('verifyHTLCSpendingConditions and isHTLCSpendAuthorised', () => { }); test('HTLC main spending pathway, no preimage (fails)', async () => { const proof: Proof = { - amount: 2n, + amount: Amount.from(2), id: '00bfa73302d12ffd', secret: `["HTLC",{"nonce":"d730dd70cd7ec6e687829857de8e70aab2b970712f4dbe288343eca20e63c28c","data":"ec4916dd28fc4c10d78e287ca5d9cc51ee1ae73cbfde08c6b37324cbfaac8bc5","tags":[["pubkeys","${PUBKEY}"]]}]`, C: '03ff6567e2e6c31db5cb7189dab2b5121930086791c93899e4eff3dda61cb57273', @@ -82,7 +82,7 @@ describe('verifyHTLCSpendingConditions and isHTLCSpendAuthorised', () => { }); test('HTLC main spending pathway, incorrect preimage (fails)', async () => { const proof: Proof = { - amount: 2n, + amount: Amount.from(2), id: '00bfa73302d12ffd', secret: `["HTLC",{"nonce":"d730dd70cd7ec6e687829857de8e70aab2b970712f4dbe288343eca20e63c28c","data":"ec4916dd28fc4c10d78e287ca5d9cc51ee1ae73cbfde08c6b37324cbfaac8bc5","tags":[["pubkeys","${PUBKEY}"]]}]`, C: '03ff6567e2e6c31db5cb7189dab2b5121930086791c93899e4eff3dda61cb57273', diff --git a/test/crypto/NUT20.test.ts b/test/crypto/NUT20.test.ts index a885527be..2bbe529c4 100644 --- a/test/crypto/NUT20.test.ts +++ b/test/crypto/NUT20.test.ts @@ -2,7 +2,7 @@ import { test, describe, expect } from 'vitest'; import { signMintQuote, verifyMintQuoteSignature } from '../../src/crypto'; import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js'; import { secp256k1 } from '@noble/curves/secp256k1.js'; -import { MintRequest } from '../../src'; +import { Amount, MintRequest } from '../../src'; describe('mint quote signatures', () => { test('valid signature verification', () => { @@ -10,27 +10,27 @@ describe('mint quote signatures', () => { quote: '9d745270-1405-46de-b5c5-e2762b4f5e00', outputs: [ { - amount: 1n, + amount: Amount.from(1), id: '00456a94ab4e1c46', B_: '0342e5bcc77f5b2a3c2afb40bb591a1e27da83cddc968abdc0ec4904201a201834', }, { - amount: 1n, + amount: Amount.from(1), id: '00456a94ab4e1c46', B_: '032fd3c4dc49a2844a89998d5e9d5b0f0b00dde9310063acb8a92e2fdafa4126d4', }, { - amount: 1n, + amount: Amount.from(1), id: '00456a94ab4e1c46', B_: '033b6fde50b6a0dfe61ad148fff167ad9cf8308ded5f6f6b2fe000a036c464c311', }, { - amount: 1n, + amount: Amount.from(1), id: '00456a94ab4e1c46', B_: '02be5a55f03e5c0aaea77595d574bce92c6d57a2a0fb2b5955c0b87e4520e06b53', }, { - amount: 1n, + amount: Amount.from(1), id: '00456a94ab4e1c46', B_: '02209fc2873f28521cbdde7f7b3bb1521002463f5979686fd156f23fe6a8aa2b79', }, @@ -49,27 +49,27 @@ describe('mint quote signatures', () => { quote: '9d745270-1405-46de-b5c5-e2762b4f5e00', outputs: [ { - amount: 1n, + amount: Amount.from(1), id: '00456a94ab4e1c46', B_: '0342e5bcc77f5b2a3c2afb40bb591a1e27da83cddc968abdc0ec4904201a201834', }, { - amount: 1n, + amount: Amount.from(1), id: '00456a94ab4e1c46', B_: '032fd3c4dc49a2844a89998d5e9d5b0f0b00dde9310063acb8a92e2fdafa4126d4', }, { - amount: 1n, + amount: Amount.from(1), id: '00456a94ab4e1c46', B_: '033b6fde50b6a0dfe61ad148fff167ad9cf8308ded5f6f6b2fe000a036c464c311', }, { - amount: 1n, + amount: Amount.from(1), id: '00456a94ab4e1c46', B_: '02be5a55f03e5c0aaea77595d574bce92c6d57a2a0fb2b5955c0b87e4520e06b53', }, { - amount: 1n, + amount: Amount.from(1), id: '00456a94ab4e1c46', B_: '02209fc2873f28521cbdde7f7b3bb1521002463f5979686fd156f23fe6a8aa2b79', }, @@ -88,27 +88,27 @@ describe('mint quote signatures', () => { quote: '9d745270-1405-46de-b5c5-e2762b4f5e00', outputs: [ { - amount: 1n, + amount: Amount.from(1), id: '00456a94ab4e1c46', B_: '0342e5bcc77f5b2a3c2afb40bb591a1e27da83cddc968abdc0ec4904201a201834', }, { - amount: 1n, + amount: Amount.from(1), id: '00456a94ab4e1c46', B_: '032fd3c4dc49a2844a89998d5e9d5b0f0b00dde9310063acb8a92e2fdafa4126d4', }, { - amount: 1n, + amount: Amount.from(1), id: '00456a94ab4e1c46', B_: '033b6fde50b6a0dfe61ad148fff167ad9cf8308ded5f6f6b2fe000a036c464c311', }, { - amount: 1n, + amount: Amount.from(1), id: '00456a94ab4e1c46', B_: '02be5a55f03e5c0aaea77595d574bce92c6d57a2a0fb2b5955c0b87e4520e06b53', }, { - amount: 1n, + amount: Amount.from(1), id: '00456a94ab4e1c46', B_: '02209fc2873f28521cbdde7f7b3bb1521002463f5979686fd156f23fe6a8aa2b79', }, diff --git a/test/crypto/NUT29.test.ts b/test/crypto/NUT29.test.ts index 98740b218..09e8e264c 100644 --- a/test/crypto/NUT29.test.ts +++ b/test/crypto/NUT29.test.ts @@ -2,6 +2,7 @@ import { test, describe, expect } from 'vitest'; import { signMintQuote, verifyMintQuoteSignature } from '../../src/crypto'; import { sha256 } from '@noble/hashes/sha2.js'; import { bytesToHex } from '@noble/hashes/utils.js'; +import { Amount } from '../../src'; /** * NUT-29 test vectors for batch mint signatures. @@ -22,12 +23,12 @@ describe('NUT-29 batch mint signatures', () => { const allOutputs = [ { - amount: 1n, + amount: Amount.from(1), id: keysetId, B_: '036d6caac248af96f6afa7f904f550253a0f3ef3f5aa2fe6838a95b216691468e2', }, { - amount: 1n, + amount: Amount.from(1), id: keysetId, B_: '021f8a566c205633d029094747d2e18f44e05993dda7a5f88f496078205f656e59', }, diff --git a/test/integration.test.ts b/test/integration.test.ts index 891341c91..5e69ca728 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -150,7 +150,7 @@ describe('mint api', () => { const proofs = await wallet.mintProofsBolt11(1337, request.quote); expect(proofs).toBeDefined(); // expect that the sum of all tokens.proofs.amount is equal to the requested amount - expect(sumProofs(proofs).toNumber()).toBe(1337); + expect(sumProofs(proofs).equals(1337)).toBeTruthy(); }); test('invoice with description', async () => { const wallet = new Wallet(mintUrl, { unit }); @@ -168,7 +168,7 @@ describe('mint api', () => { const fee = (await wallet.createMeltQuoteBolt11(invoice)).fee_reserve; expect(fee).toBeDefined(); // because external invoice, fee should be > 0 - expect(fee.toNumber()).toBeGreaterThan(0); + expect(fee.greaterThan(0)).toBeTruthy(); }); test('pay external invoice', async () => { const invoice = @@ -180,15 +180,15 @@ describe('mint api', () => { const proofs = await wallet.mintProofsBolt11(3000, request.quote); const meltQuote = await wallet.createMeltQuoteBolt11(invoice); const fee = meltQuote.fee_reserve; - expect(fee.toNumber()).toBeGreaterThan(0); + expect(fee.greaterThan(0)).toBeTruthy(); // get the quote from the mint const quote_ = await wallet.checkMeltQuoteBolt11(meltQuote.quote); expect(quote_).toBeDefined(); - const sendResponse = await wallet.send(2000 + fee.toNumber(), proofs, { includeFees: true }); + const sendResponse = await wallet.send(fee.add(2000), proofs, { includeFees: true }); const response = await wallet.meltProofsBolt11(meltQuote, sendResponse.send); expect(response).toBeDefined(); // expect that we have not received the fee back, since it was external - expect(response.change.reduce((a, b) => a + Number(b.amount), 0)).toBeLessThan(fee.toNumber()); + expect(sumProofs(response.change).lessThan(fee)).toBeTruthy(); // check states of spent and kept proofs after payment const sentProofsStates = await wallet.checkProofsStates(sendResponse.send); expect(sentProofsStates).toBeDefined(); @@ -217,7 +217,7 @@ describe('mint api', () => { expect(sendResponse.keep).toBeDefined(); expect(sendResponse.send.length).toBe(1); expect(sendResponse.keep.length).toBe(0); - expect(sumProofs(sendResponse.send).toNumber()).toBe(64); + expect(sumProofs(sendResponse.send).equals(64)).toBeTruthy(); }); test('test send tokens with change', async () => { const wallet = new Wallet(mintUrl, { unit }); @@ -233,8 +233,8 @@ describe('mint api', () => { // The 32 would have been selected (fee: 1 sat), leaving 4,64 unspent // We expect: 16, 4, 1 change + 4,64 unspent = 5 proofs (total 89) expect(sendResponse.keep.length).toBe(5); - expect(sumProofs(sendResponse.send).toNumber()).toBe(10); - expect(sumProofs(sendResponse.keep).toNumber()).toBe(89); + expect(sumProofs(sendResponse.send).equals(10)).toBeTruthy(); + expect(sumProofs(sendResponse.keep).equals(89)).toBeTruthy(); }); test('receive tokens with previous split', async () => { const wallet = new Wallet(mintUrl, { unit }); @@ -285,11 +285,7 @@ describe('mint api', () => { expect(e.message.toLowerCase()).toMatch(/witness.*p2pk.*signature/); // nutshell + cdk // Try and receive them with Bob's secret key (should suceed) const proofs = await wallet.receive(encoded, { privkey: bytesToHex(privKeyBob) }); - expect( - proofs.reduce((curr, acc) => { - return curr + Number(acc.amount); - }, 0), - ).toBe(63); + expect(sumProofs(proofs).equals(63)).toBeTruthy(); }); test('send and receive p2pk with SIG_ALL', async () => { const wallet = new Wallet(mintUrl, { unit }); @@ -319,11 +315,7 @@ describe('mint api', () => { expect(e.message.toLowerCase()).toMatch(/no witness|signatures not provided/); // nutshell + cdk // Try and receive them with Bob's secret key (should suceed) const { keep } = await wallet.completeSwap(txn, bytesToHex(privKeyBob)); - expect( - keep.reduce((curr, acc) => { - return curr + Number(acc.amount); - }, 0), - ).toBe(64); + expect(sumProofs(keep).equals(64)).toBeTruthy(); }); test('send and receive p2pk with additional tags', async () => { const wallet = new Wallet(mintUrl, { unit }); @@ -361,12 +353,7 @@ describe('mint api', () => { // Try and receive them with Alice's secret key (should succeed) const proofs = await wallet.receive(encoded, { privkey: bytesToHex(privKeyAlice) }); - - expect( - proofs.reduce((curr, acc) => { - return curr + Number(acc.amount); - }, 0), - ).toBe(63); + expect(sumProofs(proofs).equals(63)).toBeTruthy(); }); test('send and receive p2bk', async () => { @@ -399,11 +386,7 @@ describe('mint api', () => { // Try and receive them with Bob's secret key (should suceed) const proofs = await wallet.receive(encoded, { privkey: bytesToHex(privKeyBob) }); // console.log('P2BK RECEIVE', proofs); - expect( - proofs.reduce((curr, acc) => { - return curr + Number(acc.amount); - }, 0), - ).toBe(63); + expect(sumProofs(proofs).equals(63)).toBeTruthy(); }); test('send and receive p2bk SCHNORR', async () => { @@ -436,11 +419,7 @@ describe('mint api', () => { const proofs = await wallet.receive(encoded, { privkey: bytesToHex(privKeyBob) }); // console.log('P2BK RECEIVE', proofs); - expect( - proofs.reduce((curr, acc) => { - return curr + Number(acc.amount); - }, 0), - ).toBe(63); + expect(sumProofs(proofs).equals(63)).toBeTruthy(); }); test('mint and melt p2pk', async () => { const invoice = @@ -459,7 +438,7 @@ describe('mint api', () => { .run(); const meltRequest = await wallet.createMeltQuoteBolt11(invoice); const fee = meltRequest.fee_reserve; - expect(fee.toNumber()).toBeGreaterThan(0); + expect(fee.greaterThan(0)).toBeTruthy(); const signedProofs = wallet.signP2PKProofs(proofs, bytesToHex(privKeyBob)); const response = await wallet.meltProofsBolt11(meltRequest, signedProofs); expect(response).toBeDefined(); @@ -485,7 +464,7 @@ describe('mint api', () => { // console.log('input proofs', proofs); const meltRequest = await wallet.createMeltQuoteBolt11(invoice); const fee = meltRequest.fee_reserve; - expect(fee.toNumber()).toBeGreaterThan(0); + expect(fee.greaterThan(0)).toBeTruthy(); const melt = await wallet.prepareMelt('bolt11', meltRequest, proofs); const response = await wallet.completeMelt(melt, bytesToHex(privKeyBob)); expect(response).toBeDefined(); @@ -504,7 +483,7 @@ describe('mint api', () => { // prepare to melt const meltRequest = await wallet.createMeltQuoteBolt11(invoice); const fee = meltRequest.fee_reserve; - expect(fee.toNumber()).toBeGreaterThan(0); + expect(fee.greaterThan(0)).toBeTruthy(); const melt = await wallet.prepareMelt('bolt11', meltRequest, proofs); // complete melt async const response = await wallet.completeMelt(melt, undefined, true); @@ -603,7 +582,7 @@ describe('mint api', () => { const proofs = await wallet.mintProofsBolt11(63, quote.quote); // console.log( // 'proofs', - // proofs.map((p) => p.amount), + // proofs.map((p) => p.amount.toString()), // ); await new Promise((res) => { wallet.on.proofStateUpdates( @@ -677,7 +656,7 @@ describe('dleq', () => { const encodedToken = getEncodedToken(token); const newProofs = await wallet.receive(encodedToken, { requireDleq: true }); expect(newProofs).toBeDefined(); - expect(sumProofs(newProofs).toNumber()).toEqual(7); // after 1 sat fee + expect(sumProofs(newProofs).equals(7)).toBeTruthy(); // after 1 sat fee }); test('send strip dleq', async () => { const wallet = new Wallet(mintUrl); @@ -763,7 +742,7 @@ describe('Custom Outputs', () => { expectNUT10SecretDataToEqual(proofs, hexPk); // Lets melt some of these proofs to pay an invoice const meltQuote = await wallet.createMeltQuoteBolt11(invoice); - const meltAmount = meltQuote.amount.toNumber() + meltQuote.fee_reserve.toNumber(); + const meltAmount = meltQuote.amount.add(meltQuote.fee_reserve); // Use our keepFactory for the change (keep) outputs const customConfig: OutputConfig = { keep: keepFactory, @@ -885,7 +864,7 @@ describe('Keep Vector and Reordering', () => { {}, // config { type: 'random', denominations: testOutputAmounts }, // outputType ); - receiveProofs.forEach((p, i) => expect(Number(p.amount)).toBe(testOutputAmounts[i])); + receiveProofs.forEach((p, i) => expect(p.amount.equals(testOutputAmounts[i])).toBeTruthy()); }); test('Send', async () => { const wallet = new Wallet(mintUrl); @@ -900,7 +879,7 @@ describe('Keep Vector and Reordering', () => { send: { type: 'random', denominations: testOutputAmounts }, }; const { send } = await wallet.send(32, testProofs, {}, customConfig); - send.forEach((p, i) => expect(Number(p.amount)).toBe(testOutputAmounts[i])); + send.forEach((p, i) => expect(p.amount.equals(testOutputAmounts[i])).toBeTruthy()); }); test('Send with partial keep denominations (wants 16,8 but the rest can be anything)', async () => { const wallet = new Wallet(mintUrl); @@ -916,10 +895,10 @@ describe('Keep Vector and Reordering', () => { send: { type: 'random', denominations: testSendAmounts }, }; const { send, keep } = await wallet.send(32, testProofs, {}, customConfig); - // console.log(send.map((p) => p.amount)); - // console.log(keep.map((p) => p.amount)); - send.forEach((p, i) => expect(Number(p.amount)).toBe(testSendAmounts[i])); - keep.forEach((p, i) => expect(Number(p.amount)).toBe(expectedKeep[i])); + // console.log(send.map((p) => p.amount.toString())); + // console.log(keep.map((p) => p.amount.toString())); + send.forEach((p, i) => expect(p.amount.equals(testSendAmounts[i])).toBeTruthy()); + keep.forEach((p, i) => expect(p.amount.equals(expectedKeep[i])).toBeTruthy()); }); test('Send with partial send denominations (wants 16,8 but the rest can be anything)', async () => { const wallet = new Wallet(mintUrl); @@ -937,14 +916,14 @@ describe('Keep Vector and Reordering', () => { const { send, keep } = await wallet.send(32, testProofs, {}, customConfig); // console.log( // 'send', - // send.map((p) => p.amount), + // send.map((p) => p.amount.toString()), // ); // console.log( // 'keep', - // keep.map((p) => p.amount), + // keep.map((p) => p.amount.toString()), // ); - send.forEach((p, i) => expect(Number(p.amount)).toBe(expectedSend[i])); - keep.forEach((p, i) => expect(Number(p.amount)).toBe(testKeepAmounts[i])); + send.forEach((p, i) => expect(p.amount.equals(expectedSend[i])).toBeTruthy()); + keep.forEach((p, i) => expect(p.amount.equals(testKeepAmounts[i])).toBeTruthy()); }); }); describe('Wallet Restore', () => { @@ -957,7 +936,7 @@ describe('Wallet Restore', () => { const proofs = await wallet.ops.mintBolt11(70, mintQuote.quote).asDeterministic(5).run(); const { proofs: restoredProofs, lastCounterWithSignature } = await wallet.batchRestore(); expect(restoredProofs).toEqual(proofs); - expect(sumProofs(restoredProofs).toNumber()).toBe(70); + expect(sumProofs(restoredProofs).equals(70)).toBeTruthy(); expect(lastCounterWithSignature).toBe(7); }); }); @@ -1008,7 +987,7 @@ describe('CDK Mint NUT-19 Cache Tests', () => { // mint with NUT-19 cache retry - should handle network failure const proofs = await wallet.mintProofsBolt11(100, request.quote); expect(proofs).toBeDefined(); - expect(sumProofs(proofs).toNumber()).toBe(100); + expect(sumProofs(proofs).equals(100)).toBeTruthy(); expect(fetchCallCount).toBe(3); // 1 + 2 retries } finally { // restore original fetch diff --git a/test/mint/Mint.node.test.ts b/test/mint/Mint.node.test.ts index 246295054..bc689d756 100644 --- a/test/mint/Mint.node.test.ts +++ b/test/mint/Mint.node.test.ts @@ -643,7 +643,7 @@ describe('Mint normalization', () => { { amount: 2, C_: '02sig2', id: '00' }, ], }; - }); + }) as RequestFn; const mint = new Mint(mintUrl, { customRequest: requestSpy }); const response = await mint.mintBatchBolt11({ @@ -664,7 +664,7 @@ describe('Mint normalization', () => { return { signatures: [{ amount: 4, C_: '02sig', id: '00' }], }; - }); + }) as RequestFn; const mint = new Mint(mintUrl, { customRequest: requestSpy }); const response = await mint.mintBatchBolt12({ diff --git a/test/model/Amount.test.ts b/test/model/Amount.test.ts index fe7ef09f0..fb37de062 100644 --- a/test/model/Amount.test.ts +++ b/test/model/Amount.test.ts @@ -24,13 +24,13 @@ describe('Amount conversions', () => { it('constructs one and serializes safely', () => { const one = Amount.one(); expect(one.toBigInt()).toBe(1n); - expect(one.toJSON()).toBe(1); // safe integer → number + expect(one.toJSON()).toBe('1'); }); - it('converts to unsafe numbers when needed', () => { + it('serializes unsafe numbers', () => { const large = Amount.from(BigInt(Number.MAX_SAFE_INTEGER) + 10n); expect(large.toNumberUnsafe()).toBe(Number(large.toBigInt())); - expect(large.toJSON()).toBe(String(large.toBigInt())); // unsafe integer → string + expect(large.toJSON()).toBe(String(large.toBigInt())); }); }); diff --git a/test/model/SigAll.test.ts b/test/model/SigAll.test.ts index 0b709c4ea..605206bd9 100644 --- a/test/model/SigAll.test.ts +++ b/test/model/SigAll.test.ts @@ -1,16 +1,23 @@ import { test, describe, expect } from 'vitest'; import { SigAll, SigAllSigningPackage, MeltQuoteState, Amount } from '../../src'; -import type { OutputDataLike, Proof, P2PKWitness, SerializedBlindedMessage } from '../../src'; +import type { + OutputDataLike, + Proof, + P2PKWitness, + SerializedBlindedMessage, + MeltPreview, + SwapPreview, +} from '../../src'; const dummyProof: Proof = { id: 'testid', - amount: 32n, + amount: Amount.from(32), secret: 'dummysecret', C: '02' + '1'.repeat(64), }; const dummyBlindedMessage: SerializedBlindedMessage = { - amount: 32n, + amount: Amount.from(32), id: 'bm1', B_: 'dummyB', }; @@ -34,7 +41,7 @@ function makeSwapPreview() { keysetIdts: [], method: 'swap', keysetId: 'dummy-keyset-id', - }; + } as SwapPreview; } function makeMeltPreview() { @@ -43,17 +50,17 @@ function makeMeltPreview() { outputData: [dummyOutput], quote: { quote: 'dummyquote', - amount: 32, + amount: Amount.from(32), unit: 'sat', state: MeltQuoteState.PENDING, expiry: Date.now() + 10000, }, - amount: 32, + amount: Amount.from(32), fees: 0, keysetIdts: [], method: 'melt', keysetId: 'dummy-keyset-id', - }; + } as MeltPreview; } // Helper: encode an arbitrary object as a sigallA-prefixed string, @@ -64,6 +71,13 @@ function encodeRaw(obj: unknown): string { return `sigallA${b64}`; } +function decodeRawJson(input: string): string { + const base64url = input.slice('sigallA'.length); + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4); + return atob(padded); +} + describe('SigAll — computeDigests', () => { test('produces hex strings of correct length', () => { const digests = SigAll.computeDigests([dummyProof], [dummyBlindedMessage], 'dummyquote'); @@ -166,7 +180,7 @@ describe('SigAll — serializePackage / deserializePackage', () => { }); test('round-trip preserves large (unsafe integer) output amounts', () => { - const largeAmount = 9007199254740993n; // > MAX_SAFE_INTEGER + const largeAmount = Amount.from(9007199254740993n); // > MAX_SAFE_INTEGER const largeBm: SerializedBlindedMessage = { amount: largeAmount, id: 'bm-large', B_: 'dummyB' }; const pkg: SigAllSigningPackage = { version: 'sigallA', @@ -176,7 +190,40 @@ describe('SigAll — serializePackage / deserializePackage', () => { digests: SigAll.computeDigests([dummyProof], [largeBm]), }; const parsed = SigAll.deserializePackage(SigAll.serializePackage(pkg)); - expect(parsed.outputs[0].amount).toBe(largeAmount); + expect(parsed.outputs[0].amount.equals(largeAmount)).toBeTruthy(); + }); + + test('deserializePackage accepts numeric output amounts', () => { + const parsed = SigAll.deserializePackage( + encodeRaw({ + version: 'sigallA', + type: 'swap', + inputs: [{ secret: 'testsecret', C: '02' + '1'.repeat(64) }], + outputs: [{ amount: 32, id: 'bm1', B_: 'dummyB' }], + digests: SigAll.computeDigests( + [dummyProof], + [{ amount: Amount.from(32), id: 'bm1', B_: 'dummyB' }], + ), + }), + ); + + expect(parsed.outputs[0].amount.equals(Amount.from(32))).toBeTruthy(); + }); + + test('serializePackage emits unquoted integer amounts', () => { + const largeAmount = Amount.from(9007199254740993n); + const largeBm: SerializedBlindedMessage = { amount: largeAmount, id: 'bm-large', B_: 'dummyB' }; + const pkg: SigAllSigningPackage = { + version: 'sigallA', + type: 'swap', + inputs: [{ secret: 'testsecret', C: '02' + '1'.repeat(64) }], + outputs: [largeBm], + digests: SigAll.computeDigests([dummyProof], [largeBm]), + }; + + const json = decodeRawJson(SigAll.serializePackage(pkg)); + expect(json).toContain('"amount":9007199254740993'); + expect(json).not.toContain('"amount":"9007199254740993"'); }); test('serialization is deterministic', () => { @@ -201,6 +248,11 @@ describe('SigAll — serializePackage / deserializePackage', () => { expect(() => SigAll.deserializePackage(encoded)).toThrow('must be a JSON object'); }); + test('throws on invalid JSON', () => { + const encoded = 'sigallA' + btoa('{not valid json}').replace(/=+$/, ''); + expect(() => SigAll.deserializePackage(encoded)).toThrow('Failed to parse signing package'); + }); + test('throws on invalid version', () => { expect(() => SigAll.deserializePackage( @@ -310,7 +362,7 @@ describe('SigAll — serializePackage / deserializePackage', () => { digests: { current: 'a'.repeat(64) }, }), ), - ).toThrow('amount must be a number'); + ).toThrow('amount must be a number or bigint'); }); test('throws on invalid output shape — missing B_', () => { diff --git a/test/utils/core.test.ts b/test/utils/core.test.ts index d5b29930a..f4a6ad55e 100644 --- a/test/utils/core.test.ts +++ b/test/utils/core.test.ts @@ -172,7 +172,7 @@ describe('test decode token', () => { proofs: [ { C: '02195081e622f98bfc19a05ebe2341d955c0d12588c5948c858d07adec007bc1e4', - amount: 1n, + amount: Amount.from(1), id: 'I2yN+iRYfkzT', secret: '97zfmmaGf5k8Mg0gajpnbmpervTtEeE8wwKri7rWpUs=', }, @@ -195,7 +195,7 @@ describe('test decode token', () => { proofs: [ { C: '02195081e622f98bfc19a05ebe2341d955c0d12588c5948c858d07adec007bc1e4', - amount: 1n, + amount: Amount.from(1), id: 'I2yN+iRYfkzT', secret: '97zfmmaGf5k8Mg0gajpnbmpervTtEeE8wwKri7rWpUs=', }, @@ -219,7 +219,7 @@ describe('test decode token', () => { secret: '9a6dbb847bd232ba76db0df197216b29d3b8cc14553cd27827fc1cc942fedb4e', C: '038618543ffb6b8695df4ad4babcde92a34a96bdcd97dcee0d7ccf98d472126792', id: '00ad268c4d1f5826', - amount: 1n, + amount: Amount.from(1), }, ], }; @@ -239,19 +239,19 @@ describe('test decode token', () => { secret: 'acc12435e7b8484c3cf1850149218af90f716a52bf4a5ed347e48ecc13f77388', C: '0244538319de485d55bed3b29a642bee5879375ab9e7a620e11e48ba482421f3cf', id: '00ffd48b8f5ecf80', - amount: 1n, + amount: Amount.from(1), }, { secret: '1323d3d4707a58ad2e23ada4e9f1f49f5a5b4ac7b708eb0d61f738f48307e8ee', C: '023456aa110d84b4ac747aebd82c3b005aca50bf457ebd5737a4414fac3ae7d94d', id: '00ad268c4d1f5826', - amount: 2n, + amount: Amount.from(2), }, { secret: '56bcbcbb7cc6406b3fa5d57d2174f4eff8b4402b176926d3a57d3c3dcbb59d57', C: '0273129c5719e599379a974a626363c333c56cafc0e6d01abe46d5808280789c63', id: '00ad268c4d1f5826', - amount: 1n, + amount: Amount.from(1), }, ], }; @@ -276,7 +276,7 @@ describe('test getTokenMetadata', () => { incompleteProofs: [ { C: '027f390f7160a0171e0113a4311564447b2942833ae9dff0beb49cb314677ba6a4', - amount: 4n, + amount: Amount.from(4), dleq: { e: 'b5e5011baeb4c13d5c448745ae3b4dc4bf517b51e4eb03e9f74204e2c693cebe', r: '7b7e868012f0d462406be790f713d8a42762c4a9efbbe2134df1cf9fc581df98', @@ -286,7 +286,7 @@ describe('test getTokenMetadata', () => { }, { C: '03957a7e9ab75f2152ba9eb5f1b6f3cd12dbb2b3100d4fabc3fd457f95b11dcbce', - amount: 2n, + amount: Amount.from(2), dleq: { e: 'bff2e8aee32ac15b21b38e991394f23e84c56845b1f74710d6741c67117c7d11', r: '46d74791d56760b39317555f1283e2f4ad1cfa4a36a67c04e6cde510f22ed1ee', @@ -296,7 +296,7 @@ describe('test getTokenMetadata', () => { }, { C: '026f79804899c4c830475fab2bb030734f569d784cf7a02adf744e1fa695ab3d2d', - amount: 2n, + amount: Amount.from(2), dleq: { e: '3d7e00ffae6a115875b47fa4d5d41338f2105e53c354c96fe9449d1d9285d009', r: 'b1347b584b48517761475c3a58fca47f80c368fcbbe06abdfdc8258235b41e21', @@ -306,7 +306,7 @@ describe('test getTokenMetadata', () => { }, { C: '03d01a81d573403e2803496358b2abefc2f4592e51d3f41f907e2d6c4792a6518f', - amount: 1n, + amount: Amount.from(1), dleq: { e: '5b0a219b83f0a5935dbd48187d7de38b06f22093e29394e919aeb9d4e6579103', r: '16fa016d727fd7ac4cc390c18f33611d9418eba8fe557e168f671de99ae34b99', @@ -316,7 +316,7 @@ describe('test getTokenMetadata', () => { }, { C: '0342d3499d47354e8c270f3d95b37d88cd2cbbc238a13d3ff02ad0f340d59e4fdd', - amount: 1n, + amount: Amount.from(1), dleq: { e: 'cfd3ef7d297dd21a4d9a76d63947fd47eb61cae331f8b8765df3b6e46d4d30a1', r: 'b2cdebe02d50fcb9a82cd2956123a4ff868f20696fea7c3df596b2100d2968a0', @@ -338,7 +338,7 @@ describe('test getTokenMetadata', () => { incompleteProofs: [ { C: '027f390f7160a0171e0113a4311564447b2942833ae9dff0beb49cb314677ba6a4', - amount: 4n, + amount: Amount.from(4), dleq: { e: 'b5e5011baeb4c13d5c448745ae3b4dc4bf517b51e4eb03e9f74204e2c693cebe', r: '7b7e868012f0d462406be790f713d8a42762c4a9efbbe2134df1cf9fc581df98', @@ -348,7 +348,7 @@ describe('test getTokenMetadata', () => { }, { C: '03957a7e9ab75f2152ba9eb5f1b6f3cd12dbb2b3100d4fabc3fd457f95b11dcbce', - amount: 2n, + amount: Amount.from(2), dleq: { e: 'bff2e8aee32ac15b21b38e991394f23e84c56845b1f74710d6741c67117c7d11', r: '46d74791d56760b39317555f1283e2f4ad1cfa4a36a67c04e6cde510f22ed1ee', @@ -358,7 +358,7 @@ describe('test getTokenMetadata', () => { }, { C: '026f79804899c4c830475fab2bb030734f569d784cf7a02adf744e1fa695ab3d2d', - amount: 2n, + amount: Amount.from(2), dleq: { e: '3d7e00ffae6a115875b47fa4d5d41338f2105e53c354c96fe9449d1d9285d009', r: 'b1347b584b48517761475c3a58fca47f80c368fcbbe06abdfdc8258235b41e21', @@ -368,7 +368,7 @@ describe('test getTokenMetadata', () => { }, { C: '03d01a81d573403e2803496358b2abefc2f4592e51d3f41f907e2d6c4792a6518f', - amount: 1n, + amount: Amount.from(1), dleq: { e: '5b0a219b83f0a5935dbd48187d7de38b06f22093e29394e919aeb9d4e6579103', r: '16fa016d727fd7ac4cc390c18f33611d9418eba8fe557e168f671de99ae34b99', @@ -378,7 +378,7 @@ describe('test getTokenMetadata', () => { }, { C: '0342d3499d47354e8c270f3d95b37d88cd2cbbc238a13d3ff02ad0f340d59e4fdd', - amount: 1n, + amount: Amount.from(1), dleq: { e: 'cfd3ef7d297dd21a4d9a76d63947fd47eb61cae331f8b8765df3b6e46d4d30a1', r: 'b2cdebe02d50fcb9a82cd2956123a4ff868f20696fea7c3df596b2100d2968a0', @@ -447,7 +447,7 @@ describe('test v4 encoding', () => { secret: '9a6dbb847bd232ba76db0df197216b29d3b8cc14553cd27827fc1cc942fedb4e', C: '038618543ffb6b8695df4ad4babcde92a34a96bdcd97dcee0d7ccf98d472126792', id: '00ad268c4d1f5826', - amount: 1n, + amount: Amount.from(1), }, ], unit: 'sat', @@ -468,19 +468,19 @@ describe('test v4 encoding', () => { secret: 'acc12435e7b8484c3cf1850149218af90f716a52bf4a5ed347e48ecc13f77388', C: '0244538319de485d55bed3b29a642bee5879375ab9e7a620e11e48ba482421f3cf', id: '00ffd48b8f5ecf80', - amount: 1n, + amount: Amount.from(1), }, { secret: '1323d3d4707a58ad2e23ada4e9f1f49f5a5b4ac7b708eb0d61f738f48307e8ee', C: '023456aa110d84b4ac747aebd82c3b005aca50bf457ebd5737a4414fac3ae7d94d', id: '00ad268c4d1f5826', - amount: 2n, + amount: Amount.from(2), }, { secret: '56bcbcbb7cc6406b3fa5d57d2174f4eff8b4402b176926d3a57d3c3dcbb59d57', C: '0273129c5719e599379a974a626363c333c56cafc0e6d01abe46d5808280789c63', id: '00ad268c4d1f5826', - amount: 1n, + amount: Amount.from(1), }, ], unit: 'sat', @@ -493,7 +493,7 @@ describe('test v4 encoding', () => { expect(decodedExpectedToken).toEqual(decodedEncodedToken); }); test('bigint amount > MAX_SAFE_INTEGER roundtrips through v4 encoding', () => { - const largeAmount = 2n ** 53n + 1n; // 9007199254740993n — first integer above MAX_SAFE_INTEGER + const largeAmount = Amount.from(2n ** 53n + 1n); // 9007199254740993 — first integer above MAX_SAFE_INTEGER const token = { mint: 'http://localhost:3338', proofs: [ @@ -508,7 +508,27 @@ describe('test v4 encoding', () => { }; const encoded = utils.getEncodedToken(token); const decoded = utils.getDecodedToken(encoded, ['009a1f293253e41e']); - expect(decoded.proofs[0].amount).toBe(largeAmount); + expect(decoded.proofs[0].amount.equals(largeAmount)).toBe(true); + }); + test('getEncodedToken accepts JSON-parsed tokens and rehydrates proof amounts', () => { + const token = { + mint: 'http://localhost:3338', + proofs: [ + { + secret: '9a6dbb847bd232ba76db0df197216b29d3b8cc14553cd27827fc1cc942fedb4e', + C: '038618543ffb6b8695df4ad4babcde92a34a96bdcd97dcee0d7ccf98d472126792', + id: '00ad268c4d1f5826', + amount: Amount.from(2n ** 53n + 1n), + }, + ], + unit: 'sat', + }; + const parsedToken = JSON.parse(JSON.stringify(token)) as Token; + + const encoded = utils.getEncodedToken(parsedToken); + const decoded = utils.getDecodedToken(encoded, ['009a1f293253e41e']); + + expect(decoded.proofs[0].amount.equals(token.proofs[0].amount)).toBe(true); }); test('getEncodedToken does not mutate input token proof IDs', () => { const token = { @@ -516,7 +536,7 @@ describe('test v4 encoding', () => { proofs: [ { id: '01884a74bb2fc5ee6e5f958f89f9e4e6cf79241fbc9fd1012d6811b054a78beffe', - amount: 1n, + amount: Amount.from(1), secret: '9a6dbb847bd232ba76db0df197216b29d3b8cc14553cd27827fc1cc942fedb4e', C: '02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904', }, @@ -530,7 +550,7 @@ describe('test v4 encoding', () => { test('removing DLEQ', async () => { const proofs = [ { - amount: 1n, + amount: Amount.from(1), C: '03ff2e729416437f9ea8d022c501ff5b309d607f98c9ab53d51cd24185b4d3e42b', id: '00b4cd27d8861a44', secret: '10216467bb33f6f079ae92349ba54fa34df99ba24572645b8b813688c74b582d', @@ -542,7 +562,7 @@ describe('test v4 encoding', () => { }, }, { - amount: 4n, + amount: Amount.from(4), C: '02b457f8e1e151cd71dd3246b56d0f479ac63786e71916b46d16369cb6f78024b9', id: '00b4cd27d8861a44', secret: '1b1bc7a099a63c808c17f8ca4ede03f30d3c243ca34ec4d10a1327b7cfb3ead7', @@ -554,7 +574,7 @@ describe('test v4 encoding', () => { }, }, { - amount: 16n, + amount: Amount.from(16), C: '03570cdf33bc832a60660b3e7d8ddb74d0dd3158e0fde5b0f607555bb7e8e9fb0f', id: '00b4cd27d8861a44', secret: '8425354533436ca7c29b34daae3aef85ab08925c810d1db4f005259d79d7f9f6', @@ -607,7 +627,7 @@ describe('test zero-knowledge utilities', () => { // construct Proof directly (amount = 1, matching keyset key in tests below) const serializedProof: Proof = { id: unblinded.id, - amount: 1n, + amount: Amount.from(1), C: unblinded.C.toHex(true), secret: new TextDecoder().decode(unblinded.secret), dleq: { @@ -642,7 +662,7 @@ describe('test raw tokens', () => { proofs: [ { id: '00ad268c4d1f5826', - amount: 1n, + amount: Amount.from(1), secret: '9a6dbb847bd232ba76db0df197216b29d3b8cc14553cd27827fc1cc942fedb4e', C: '038618543ffb6b8695df4ad4babcde92a34a96bdcd97dcee0d7ccf98d472126792', }, @@ -665,6 +685,15 @@ describe('test raw tokens', () => { const decodedToken = utils.getDecodedTokenBinary(bytes); expect(decodedToken).toEqual(token); }); + + test('getEncodedTokenBinary accepts JSON-parsed tokens and rehydrates proof amounts', () => { + const parsedToken = JSON.parse(JSON.stringify(token)) as Token; + + const bytes = utils.getEncodedTokenBinary(parsedToken); + const decodedToken = utils.getDecodedTokenBinary(bytes); + + expect(decodedToken).toEqual(token); + }); }); describe('test deprecated base64 keyset id derivation', () => { @@ -858,8 +887,8 @@ describe('invoiceHasAmountInHRP()', () => { describe('serializeProofs / deserializeProofs / normalizeProofAmounts', () => { const proofs: Proof[] = [ - { id: '009a1f293253e41e', amount: 1n, secret: 'abc', C: '02abc' }, - { id: '009a1f293253e41e', amount: 2n, secret: 'def', C: '02def' }, + { id: '009a1f293253e41e', amount: Amount.from(1), secret: 'abc', C: '02abc' }, + { id: '009a1f293253e41e', amount: Amount.from(2), secret: 'def', C: '02def' }, ]; describe('serializeProofs', () => { @@ -890,29 +919,29 @@ describe('serializeProofs / deserializeProofs / normalizeProofAmounts', () => { }); describe('deserializeProofs', () => { - test('restores bigint amounts from string[] (NutZap / DB)', () => { + test('restores Amount objects from string[] (NutZap / DB)', () => { const strings = serializeProofs(proofs); const restored = deserializeProofs(strings); - expect(restored[0].amount).toBe(1n); - expect(restored[1].amount).toBe(2n); - expect(typeof restored[0].amount).toBe('bigint'); + expect(restored[0].amount.equals(1)).toBe(true); + expect(restored[1].amount.equals(2)).toBe(true); + expect(restored[0].amount).toBeInstanceOf(Amount); }); - test('restores bigint amounts from raw localStorage string (serializeProofs blob)', () => { + test('restores Amount objects from raw localStorage string (serializeProofs blob)', () => { // serializeProofs returns string[], JSON.stringify wraps it as a JSON array of strings const raw = JSON.stringify(serializeProofs(proofs)); // pass the raw string directly — no JSON.parse needed const restored = deserializeProofs(raw); - expect(restored[0].amount).toBe(1n); - expect(restored[1].amount).toBe(2n); + expect(restored[0].amount.equals(1)).toBe(true); + expect(restored[1].amount.equals(2)).toBe(true); }); - test('restores bigint amounts from JSON.parse of localStorage (string[])', () => { + test('restores Amount objects from JSON.parse of localStorage (string[])', () => { const json = JSON.stringify(serializeProofs(proofs)); // JSON.parse gives string[], deserializeProofs accepts that too const restored = deserializeProofs(JSON.parse(json)); - expect(restored[0].amount).toBe(1n); - expect(restored[1].amount).toBe(2n); + expect(restored[0].amount.equals(1)).toBe(true); + expect(restored[1].amount.equals(2)).toBe(true); }); test('round-trips all proof fields', () => { @@ -923,10 +952,10 @@ describe('serializeProofs / deserializeProofs / normalizeProofAmounts', () => { }); test('handles amounts above MAX_SAFE_INTEGER without precision loss', () => { - const large = 2n ** 53n + 1n; + const large = Amount.from(2n ** 53n + 1n); const p: Proof[] = [{ id: '009a1f293253e41e', amount: large, secret: 'abc', C: '02abc' }]; const restored = deserializeProofs(serializeProofs(p)); - expect(restored[0].amount).toBe(large); + expect(restored[0].amount.equals(large)).toBe(true); }); test('handles empty string[] input', () => { @@ -949,25 +978,25 @@ describe('serializeProofs / deserializeProofs / normalizeProofAmounts', () => { // Simulates: deserializeProofs(JSON.parse(localStorage.getItem('proofs'))) // where localStorage held a plain JSON array of objects from v3 const restored = deserializeProofs(legacyObjects); - expect(restored[0].amount).toBe(1n); - expect(restored[1].amount).toBe(2n); + expect(restored[0].amount.equals(1)).toBe(true); + expect(restored[1].amount.equals(2)).toBe(true); expect(restored[0].id).toBe('009a1f293253e41e'); expect(restored[0].secret).toBe('abc'); }); }); describe('normalizeProofAmounts', () => { - test('converts number amounts to bigint', () => { + test('converts number amounts to Amount', () => { const raw = [{ id: '009a1f293253e41e', amount: 4, secret: 'abc', C: '02abc' }]; const normalized = normalizeProofAmounts(raw); - expect(normalized[0].amount).toBe(4n); - expect(typeof normalized[0].amount).toBe('bigint'); + expect(normalized[0].amount.equals(4)).toBe(true); + expect(normalized[0].amount).toBeInstanceOf(Amount); }); test('accepts string amounts', () => { const raw = [{ id: '009a1f293253e41e', amount: '8', secret: 'abc', C: '02abc' }]; const normalized = normalizeProofAmounts(raw); - expect(normalized[0].amount).toBe(8n); + expect(normalized[0].amount.equals(8)).toBe(true); }); }); }); @@ -1004,9 +1033,9 @@ describe('getKeysetAmounts', () => { describe('sortProofsById', () => { test('sorts proofs by keyset id lexicographically', () => { const proofs: Proof[] = [ - { id: 'ccc', amount: 1n, secret: 'a', C: '02a' }, - { id: 'aaa', amount: 2n, secret: 'b', C: '02b' }, - { id: 'bbb', amount: 4n, secret: 'c', C: '02c' }, + { id: 'ccc', amount: Amount.from(1), secret: 'a', C: '02a' }, + { id: 'aaa', amount: Amount.from(2), secret: 'b', C: '02b' }, + { id: 'bbb', amount: Amount.from(4), secret: 'c', C: '02c' }, ]; const sorted = sortProofsById(proofs); expect(sorted.map((p) => p.id)).toStrictEqual(['aaa', 'bbb', 'ccc']); @@ -1014,8 +1043,8 @@ describe('sortProofsById', () => { test('does not mutate the original array', () => { const proofs: Proof[] = [ - { id: 'bbb', amount: 1n, secret: 'a', C: '02a' }, - { id: 'aaa', amount: 2n, secret: 'b', C: '02b' }, + { id: 'bbb', amount: Amount.from(1), secret: 'a', C: '02a' }, + { id: 'aaa', amount: Amount.from(2), secret: 'b', C: '02b' }, ]; sortProofsById(proofs); expect(proofs[0].id).toBe('bbb'); @@ -1026,7 +1055,7 @@ describe('getEncodedToken edge cases', () => { test('throws for proofs with non-hex keyset IDs', () => { const token: Token = { mint: 'http://localhost:3338', - proofs: [{ id: 'not+hex!', amount: 1n, secret: 'abc', C: '02abc' }], + proofs: [{ id: 'not+hex!', amount: Amount.from(1), secret: 'abc', C: '02abc' }], unit: 'sat', }; expect(() => utils.getEncodedToken(token)).toThrow(/legacy keyset ID/); @@ -1055,7 +1084,7 @@ describe('mapShortKeysetIds via getDecodedToken (v2 keyset IDs)', () => { // Encode a token using the full v2 ID — internally it gets truncated to 16 chars const token: Token = { mint: 'http://localhost:3338', - proofs: [{ id: fullV2Id, amount: 1n, secret: 'abc', C: '02' + '00'.repeat(32) }], + proofs: [{ id: fullV2Id, amount: Amount.from(1), secret: 'abc', C: '02' + '00'.repeat(32) }], unit: 'sat', }; const encoded = utils.getEncodedToken(token); @@ -1067,7 +1096,7 @@ describe('mapShortKeysetIds via getDecodedToken (v2 keyset IDs)', () => { test('throws when v2 short ID has no keysets to map to', () => { const token: Token = { mint: 'http://localhost:3338', - proofs: [{ id: fullV2Id, amount: 1n, secret: 'abc', C: '02' + '00'.repeat(32) }], + proofs: [{ id: fullV2Id, amount: Amount.from(1), secret: 'abc', C: '02' + '00'.repeat(32) }], unit: 'sat', }; const encoded = utils.getEncodedToken(token); @@ -1079,7 +1108,7 @@ describe('mapShortKeysetIds via getDecodedToken (v2 keyset IDs)', () => { test('throws when v2 short ID matches no known keyset', () => { const token: Token = { mint: 'http://localhost:3338', - proofs: [{ id: fullV2Id, amount: 1n, secret: 'abc', C: '02' + '00'.repeat(32) }], + proofs: [{ id: fullV2Id, amount: Amount.from(1), secret: 'abc', C: '02' + '00'.repeat(32) }], unit: 'sat', }; const encoded = utils.getEncodedToken(token); @@ -1092,7 +1121,7 @@ describe('mapShortKeysetIds via getDecodedToken (v2 keyset IDs)', () => { test('throws when v2 short ID is ambiguous', () => { const token: Token = { mint: 'http://localhost:3338', - proofs: [{ id: fullV2Id, amount: 1n, secret: 'abc', C: '02' + '00'.repeat(32) }], + proofs: [{ id: fullV2Id, amount: Amount.from(1), secret: 'abc', C: '02' + '00'.repeat(32) }], unit: 'sat', }; const encoded = utils.getEncodedToken(token); diff --git a/test/wallet/WalletEvents.test.ts b/test/wallet/WalletEvents.test.ts index a126cb3b9..2a0de1a13 100644 --- a/test/wallet/WalletEvents.test.ts +++ b/test/wallet/WalletEvents.test.ts @@ -3,6 +3,7 @@ import { WalletEvents } from '../../src/wallet/WalletEvents'; import type { Proof } from '../../src/model/types'; import { hashToCurve } from '../../src/crypto'; import { OperationCounters } from '../../src/wallet'; +import { Amount } from '../../src'; // Helper: flush microtasks (needed because cancelSafely runs them async) const flushMicrotasks = async (n = 2) => { @@ -173,7 +174,9 @@ describe('WalletEvents', () => { it('proofStateUpdates subscribes and forwards payloads with proof attached', async () => { const cb = vi.fn(); const err = vi.fn(); - const proofs: Proof[] = [{ amount: 2n, id: '00bd033559de27d0', secret: 's1', C: 'test' }]; + const proofs: Proof[] = [ + { amount: Amount.from(2), id: '00bd033559de27d0', secret: 's1', C: 'test' }, + ]; await events.proofStateUpdates(proofs, cb, err); const ws = mock.mint.webSocketConnection!; @@ -383,8 +386,8 @@ describe('WalletEvents', () => { describe('proofStatesStream', () => { it('yields payloads until error completes the stream, then cancels', async () => { const proofs: Proof[] = [ - { amount: 2n, id: '00bd033559de27d0', secret: 's1', C: 'test' }, - { amount: 2n, id: '00bd033559de27d0', secret: 's2', C: 'test2' }, + { amount: Amount.from(2), id: '00bd033559de27d0', secret: 's1', C: 'test' }, + { amount: Amount.from(2), id: '00bd033559de27d0', secret: 's2', C: 'test2' }, ]; const iter = events.proofStatesStream(proofs); @@ -412,7 +415,9 @@ describe('WalletEvents', () => { }); it('aborts the stream and cancels subscription', async () => { - const proofs: Proof[] = [{ amount: 2n, id: '00bd033559de27d0', secret: 's1', C: 'test' }]; + const proofs: Proof[] = [ + { amount: Amount.from(2), id: '00bd033559de27d0', secret: 's1', C: 'test' }, + ]; const ac = new AbortController(); const iter = events.proofStatesStream(proofs, { signal: ac.signal }); @@ -437,7 +442,7 @@ describe('WalletEvents', () => { }); it('buffers with maxBuffer, drops oldest by default, calls onDrop', async () => { - const proofs: Proof[] = [{ amount: 1n, id: 'i', secret: 's', C: 'c' }]; + const proofs: Proof[] = [{ amount: Amount.from(1), id: 'i', secret: 's', C: 'c' }]; const dropped: any[] = []; const iter = events.proofStatesStream(proofs, { maxBuffer: 2, @@ -467,7 +472,7 @@ describe('WalletEvents', () => { }); it('drop:newest discards incoming payload and reports it via onDrop', async () => { - const proofs: Proof[] = [{ amount: 1n, id: 'i', secret: 's', C: 'c' }]; + const proofs: Proof[] = [{ amount: Amount.from(1), id: 'i', secret: 's', C: 'c' }]; const dropped: any[] = []; const iter = events.proofStatesStream(proofs, { maxBuffer: 2, @@ -498,7 +503,7 @@ describe('WalletEvents', () => { }); it('onDrop exceptions are swallowed', async () => { - const proofs: Proof[] = [{ amount: 1n, id: 'i', secret: 's', C: 'c' }]; + const proofs: Proof[] = [{ amount: Amount.from(1), id: 'i', secret: 's', C: 'c' }]; const iter = events.proofStatesStream(proofs, { maxBuffer: 1, onDrop: () => { @@ -592,7 +597,7 @@ describe('WalletEvents', () => { describe('proofStatesStream immediate abort', () => { it('does not emit and cancels immediately when signal is already aborted', async () => { - const proofs: Proof[] = [{ amount: 1n, id: 'z', secret: 's', C: 'c' }]; + const proofs: Proof[] = [{ amount: Amount.from(1), id: 'z', secret: 's', C: 'c' }]; const ac = new AbortController(); ac.abort(); // aborted before creating the stream @@ -610,7 +615,7 @@ describe('WalletEvents', () => { describe("proofStatesStream drop:'newest' without onDrop", () => { it('drops the incoming payload and does not enqueue it (no onDrop provided)', async () => { - const proofs: Proof[] = [{ amount: 1n, id: 'd', secret: 's', C: 'c' }]; + const proofs: Proof[] = [{ amount: Amount.from(1), id: 'd', secret: 's', C: 'c' }]; const iter = events.proofStatesStream(proofs, { maxBuffer: 1, drop: 'newest' }); const out: any[] = []; @@ -633,7 +638,7 @@ describe('WalletEvents', () => { }); it('drops incoming payload silently when buffer full and no onDrop provided', async () => { - const proofs: Proof[] = [{ amount: 1n, id: 'd', secret: 's', C: 'c' }]; + const proofs: Proof[] = [{ amount: Amount.from(1), id: 'd', secret: 's', C: 'c' }]; const iter = events.proofStatesStream(proofs, { maxBuffer: 1, drop: 'newest' }); const out: any[] = []; diff --git a/test/wallet/WalletOps.test.ts b/test/wallet/WalletOps.test.ts index 81307d6de..25a6a14a9 100644 --- a/test/wallet/WalletOps.test.ts +++ b/test/wallet/WalletOps.test.ts @@ -169,8 +169,8 @@ class MockWallet { // ---- Fixtures --------------------------------------------------------------- const proofs: Proof[] = [ - { amount: 2n, id: '00bd033559de27d0', secret: 'test', C: 'test' }, - { amount: 3n, id: '00bd033559de27d0', secret: 'test', C: 'test' }, + { amount: Amount.from(2), id: '00bd033559de27d0', secret: 'test', C: 'test' }, + { amount: Amount.from(3), id: '00bd033559de27d0', secret: 'test', C: 'test' }, ]; const token = @@ -230,7 +230,7 @@ describe('WalletOps builders', () => { expect(wallet.send).toHaveBeenCalledTimes(1); const [amount, sentProofs, config, maybeOutputConfig] = wallet.send.mock.calls[0]; - expect(Amount.from(amount).toNumber()).toBe(5); + expect(Amount.from(amount).equals(5)).toBeTruthy(); expect(sentProofs).toBe(proofs); expect(config).toEqual({ includeFees: true, keysetId: 'kid' }); expect(maybeOutputConfig).toEqual({ @@ -241,7 +241,7 @@ describe('WalletOps builders', () => { it('accepts AmountLike for send amount', async () => { await ops.send(Amount.from(5), proofs).run(); expect(wallet.send).toHaveBeenCalledTimes(1); - expect(Amount.from(wallet.send.mock.calls[0][0]).toNumber()).toBe(5); + expect(Amount.from(wallet.send.mock.calls[0][0]).equals(5)).toBeTruthy(); }); it('calls wallet.prepareSwapToSend with defaults when no OutputType was set', async () => { @@ -251,7 +251,7 @@ describe('WalletOps builders', () => { const [amount, sentProofs, config, maybeOutputConfig] = wallet.prepareSwapToSend.mock.calls[0]; - expect(Amount.from(amount).toNumber()).toBe(5); + expect(Amount.from(amount).equals(5)).toBeTruthy(); expect(sentProofs).toBe(proofs); expect(config).toEqual({ includeFees: true, keysetId: 'kid' }); expect(maybeOutputConfig).toEqual({ @@ -299,8 +299,8 @@ describe('WalletOps builders', () => { expect(wallet.sendOffline).toHaveBeenCalledTimes(1); const [amount, sentProofs, config] = wallet.sendOffline.mock.calls[0]; - expect(Amount.from(amount).toNumber()).toBe(5); - expect(sentProofs).toBe(proofs); + expect(Amount.from(amount).equals(5)).toBeTruthy(); + expect(sentProofs).toStrictEqual(proofs); expect(config).toEqual({ includeFees: true, exactMatch: true, requireDleq: false }); expect(wallet.send).not.toHaveBeenCalled(); }); @@ -532,7 +532,7 @@ describe('WalletOps builders', () => { const [method, amount, q, config, maybeOT] = wallet.prepareMint.mock.calls[0]; expect(method).toBe('bolt11'); - expect(Amount.from(amount).toNumber()).toBe(10); + expect(Amount.from(amount).equals(10)).toBeTruthy(); // MintBuilder resolves string quote IDs via checkMintQuoteBolt11 before calling prepareMint expect(q.quote).toBe(quote); expect(config).toEqual({ keysetId: 'kid' }); @@ -542,7 +542,7 @@ describe('WalletOps builders', () => { it('accepts AmountLike for mint amount', async () => { await ops.mintBolt11('10', quote).prepare(); expect(wallet.prepareMint).toHaveBeenCalledTimes(1); - expect(Amount.from(wallet.prepareMint.mock.calls[0][1]).toNumber()).toBe(10); + expect(Amount.from(wallet.prepareMint.mock.calls[0][1]).equals(10)).toBeTruthy(); }); it('calls wallet.prepareMint with custom OutputType and config', async () => { @@ -557,7 +557,7 @@ describe('WalletOps builders', () => { const [method, amount, q, config, outputType] = wallet.prepareMint.mock.calls[0]; expect(method).toBe('bolt11'); - expect(Amount.from(amount).toNumber()).toBe(10); + expect(Amount.from(amount).equals(10)).toBeTruthy(); // MintBuilder resolves string quote IDs via checkMintQuoteBolt11 before calling prepareMint expect(q.quote).toBe(quote); expect(outputType).toEqual({ type: 'p2pk', options: { pubkey: 'P' }, denominations: [10] }); @@ -651,7 +651,7 @@ describe('WalletOps builders', () => { const [method, amount, q, cfg, ot] = wallet.prepareMint.mock.calls[0]; expect(method).toBe('bolt12'); - expect(Amount.from(amount).toNumber()).toBe(7); + expect(Amount.from(amount).equals(7)).toBeTruthy(); expect(q).toBe(mint12); expect(cfg).toMatchObject({ keysetId: 'kid', privkey: 'sk' }); expect(typeof cfg!.onCountersReserved).toBe('function'); diff --git a/test/wallet/_internal.test.ts b/test/wallet/_internal.test.ts index c1d82a749..5fa8f1d78 100644 --- a/test/wallet/_internal.test.ts +++ b/test/wallet/_internal.test.ts @@ -1,13 +1,14 @@ import { test, describe, expect } from 'vitest'; -import { type Keys, type Proof } from '../../src'; +import { Amount, type Keys, type Proof, OutputType } from '../../src'; import { PUBKEYS } from '../consts'; -import { getKeepAmounts } from '../../src/wallet/_internal'; +import { getKeepAmounts, stringifyOutputTypeForLog } from '../../src/wallet/_internal'; +import { OutputData } from '../../src/model/OutputData'; describe('getKeepAmounts', () => { const amountsWeHave = [1, 2, 4, 4, 4, 8]; const proofsWeHave = amountsWeHave.map((amount) => { return { - amount: BigInt(amount), + amount: Amount.from(amount), id: 'id', C: 'C', } as Proof; @@ -35,3 +36,103 @@ describe('getKeepAmounts', () => { expect(amountsToKeep.map((a) => a.toNumber())).toEqual([1, 1, 2, 2, 8, 8]); }); }); + +describe('stringifyOutputTypeForLog', () => { + const keyset = { id: '00bd033559de27d0', keys: PUBKEYS as Keys }; + + test('formats random denominations as strings', () => { + const result = stringifyOutputTypeForLog({ + type: 'random', + denominations: [Amount.from(1), 2n, '4'], + }); + expect(result).toBe(JSON.stringify({ type: 'random', denominations: ['1', '2', '4'] })); + }); + + test('formats deterministic denominations and counter', () => { + const result = stringifyOutputTypeForLog({ + type: 'deterministic', + counter: 7, + denominations: [1, Amount.from(2)], + }); + expect(result).toBe( + JSON.stringify({ type: 'deterministic', counter: 7, denominations: ['1', '2'] }), + ); + }); + + test('formats factory denominations as strings', () => { + const result = stringifyOutputTypeForLog({ + type: 'factory', + factory: (amount, keys) => OutputData.createRandomData(amount, keys)[0], + denominations: [1, Amount.from(2)], + }); + expect(result).toBe(JSON.stringify({ type: 'factory', denominations: ['1', '2'] })); + }); + + test('formats p2pk denominations as strings', () => { + const result = stringifyOutputTypeForLog({ + type: 'p2pk', + options: { pubkey: '02'.padEnd(66, '1') }, + denominations: [1, Amount.from(2)], + }); + expect(result).toBe( + JSON.stringify({ + type: 'p2pk', + options: { pubkey: '02'.padEnd(66, '1') }, + denominations: ['1', '2'], + }), + ); + }); + + test('formats custom outputs as amount strings without serializing bigint internals', () => { + const data = OutputData.createRandomData(3, keyset, [1, 2]); + const result = stringifyOutputTypeForLog({ + type: 'custom', + data, + }); + expect(result).toBe(JSON.stringify({ type: 'custom', outputs: 2, amounts: ['1', '2'] })); + }); + + test('formats empty denominations for all non-custom output types', () => { + expect( + stringifyOutputTypeForLog({ + type: 'random', + }), + ).toBe(JSON.stringify({ type: 'random', denominations: [] })); + + expect( + stringifyOutputTypeForLog({ + type: 'deterministic', + counter: 0, + }), + ).toBe(JSON.stringify({ type: 'deterministic', counter: 0, denominations: [] })); + + expect( + stringifyOutputTypeForLog({ + type: 'factory', + factory: (amount, keys) => OutputData.createRandomData(amount, keys)[0], + }), + ).toBe(JSON.stringify({ type: 'factory', denominations: [] })); + + expect( + stringifyOutputTypeForLog({ + type: 'p2pk', + options: { pubkey: '02'.padEnd(66, '1') }, + }), + ).toBe( + JSON.stringify({ + type: 'p2pk', + options: { pubkey: '02'.padEnd(66, '1') }, + denominations: [], + }), + ); + }); + + test('returns unknown for unknown type', () => { + const data = OutputData.createRandomData(3, keyset, [1, 2]); + const result = stringifyOutputTypeForLog({ + type: 'badtype', + data, + } as unknown as OutputType); + expect(result).toBe('Unknown'); + }); +}); diff --git a/test/wallet/bolt12.test.ts b/test/wallet/bolt12.test.ts index 66e9a3d0f..03f69d987 100644 --- a/test/wallet/bolt12.test.ts +++ b/test/wallet/bolt12.test.ts @@ -488,7 +488,7 @@ describe('Wallet (BOLT12) – wrappers', () => { unit: 'sat', request: 'lno1offer...', }; - const proof: Proof = { amount: 128n, secret: 'secret1', C: 'C1', id: 'foo' }; + const proof: Proof = { amount: Amount.from(128), secret: 'secret1', C: 'C1', id: 'foo' }; const res = await wallet.meltProofsBolt12(meltQuote as any, [proof]); expect(res.quote.quote).toEqual('m1'); expect(res.change).toEqual([]); diff --git a/test/wallet/paymentRequests.test.ts b/test/wallet/paymentRequests.test.ts index 9416a06a9..e940baee0 100644 --- a/test/wallet/paymentRequests.test.ts +++ b/test/wallet/paymentRequests.test.ts @@ -34,7 +34,7 @@ describe('payment requests', () => { const decodedRequest = decodePaymentRequest(pr); expect(decodedRequest).toBeDefined(); expect(decodedRequest.id).toBe('4840f51e'); - expect(decodedRequest.amount?.toNumber()).toBe(1000); + expect(decodedRequest.amount?.equals(1000)).toBeTruthy(); expect(decodedRequest.unit).toBe('sat'); expect(decodedRequest.mints).toStrictEqual(['https://mint.com']); expect(decodedRequest.description).toBe('test'); @@ -136,7 +136,7 @@ describe('payment requests', () => { // Decode and verify all fields const decoded = PaymentRequest.fromEncodedRequest(encoded); expect(decoded.id).toBe('test_id_123'); - expect(decoded.amount?.toNumber()).toBe(500); + expect(decoded.amount?.equals(500)).toBeTruthy(); expect(decoded.unit).toBe('sat'); expect(decoded.mints).toEqual(['https://mint.example.com']); expect(decoded.description).toBe('Test payment request'); @@ -169,7 +169,7 @@ describe('payment requests', () => { const decoded = PaymentRequest.fromEncodedRequest(encoded); expect(decoded.id).toBe('http_test'); - expect(decoded.amount?.toNumber()).toBe(250); + expect(decoded.amount?.equals(250)).toBeTruthy(); expect(decoded.transport![0].type).toBe(PaymentRequestTransportType.POST); expect(decoded.transport![0].target).toBe('https://api.example.com/payment'); expect(decoded.transport![0].tags).toEqual([ @@ -216,7 +216,7 @@ describe('payment requests', () => { const decoded = PaymentRequest.fromEncodedRequest(encoded); expect(decoded.id).toBe('p2pk_test'); - expect(decoded.amount?.toNumber()).toBe(1000); + expect(decoded.amount?.equals(1000)).toBeTruthy(); expect(decoded.unit).toBe('sat'); expect(decoded.description).toBe('Locked payment'); // Note: nut10 is decoded from creqB format, but only first entry is stored @@ -233,7 +233,7 @@ describe('payment requests', () => { // Verify all fields preserved expect(decoded.id).toBe(pr.id); - expect(decoded.amount?.toNumber()).toBe(pr.amount?.toNumber()); + expect(decoded.amount?.equals(pr.amount!)).toBeTruthy(); expect(decoded.unit).toBe(pr.unit); expect(decoded.mints).toEqual(pr.mints); expect(decoded.transport![0].type).toBe(pr.transport![0].type); diff --git a/test/wallet/selectProofsRGLI.test.ts b/test/wallet/selectProofsRGLI.test.ts index 6788ec94a..1625b56c7 100644 --- a/test/wallet/selectProofsRGLI.test.ts +++ b/test/wallet/selectProofsRGLI.test.ts @@ -2,6 +2,7 @@ import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; import { selectProofsRGLI } from '../../src/wallet/SelectProofs'; import { Proof } from '../../src/model/types'; import { Amount } from '../../src/model/Amount'; +import { sumProofs } from '../../src'; // ----------------------------------------------------------------- // Most paths are exercised via wallet tests. @@ -29,14 +30,14 @@ beforeEach(() => { }); afterEach(() => { - vi.resetModules(); // clean up any module mocks from the timeout test + vi.resetModules(); }); describe('selectProofsRGLI, focused unit tests', () => { test('returns keep all when everything becomes uneconomical with fees', () => { const proofs: Proof[] = [ - { id: 'A', amount: 1n, secret: 's1', C: 'C1' }, - { id: 'A', amount: 1n, secret: 's2', C: 'C2' }, + { id: 'A', amount: Amount.from(1), secret: 's1', C: 'C1' }, + { id: 'A', amount: Amount.from(1), secret: 's2', C: 'C2' }, ]; // fee ppk two thousand, ceil(2000, 1000) equals two, exFee becomes negative const kc = keychainStub({ A: 2000 }); @@ -49,8 +50,8 @@ describe('selectProofsRGLI, focused unit tests', () => { test('exact match, pre trim to zero when all exFee are greater than target', () => { const proofs: Proof[] = [ - { id: 'A', amount: 10n, secret: 's1', C: 'C1' }, - { id: 'A', amount: 12n, secret: 's2', C: 'C2' }, + { id: 'A', amount: Amount.from(10), secret: 's1', C: 'C1' }, + { id: 'A', amount: Amount.from(12), secret: 's2', C: 'C2' }, ]; const kc = keychainStub({ A: 0 }); // target smaller than smallest candidate, binary search returns null, endIndex zero @@ -61,8 +62,8 @@ describe('selectProofsRGLI, focused unit tests', () => { test('close match, biggerIndex null branch, all exFee less than target so all kept for selection', () => { const proofs: Proof[] = [ - { id: 'A', amount: 3n, secret: 's1', C: 'C1' }, - { id: 'A', amount: 4n, secret: 's2', C: 'C2' }, + { id: 'A', amount: Amount.from(3), secret: 's1', C: 'C1' }, + { id: 'A', amount: Amount.from(4), secret: 's2', C: 'C2' }, ]; const kc = keychainStub({ A: 0 }); const res = selectProofsRGLI(proofs as any, 6, kc, false, false); @@ -72,18 +73,33 @@ describe('selectProofsRGLI, focused unit tests', () => { test('accepts AmountLike target amount', () => { const proofs: Proof[] = [ - { id: 'A', amount: 3n, secret: 's1', C: 'C1' }, - { id: 'A', amount: 4n, secret: 's2', C: 'C2' }, + { id: 'A', amount: Amount.from(3), secret: 's1', C: 'C1' }, + { id: 'A', amount: Amount.from(4), secret: 's2', C: 'C2' }, ]; const kc = keychainStub({ A: 0 }); const res = selectProofsRGLI(proofs as any, Amount.from('6'), kc, false, false); expect(res.send.length).toBeGreaterThan(0); }); + test('accepts JSON-parsed ProofLike[] and rehydrates proof amounts', () => { + const proofs = JSON.parse( + JSON.stringify([ + { id: 'A', amount: Amount.from(4), secret: 's1', C: 'C1' }, + { id: 'A', amount: Amount.from(4), secret: 's2', C: 'C2' }, + ]), + ) as Proof[]; + const kc = keychainStub({ A: 0 }); + + const res = selectProofsRGLI(proofs, 6, kc, false, false); + + expect(res.send.length).toBeGreaterThan(0); + expect(res.send.every((p) => p.amount instanceof Amount)).toBe(true); + }); + test('no feasible solution, amount exceeds total after fees, returns keep all and empty send', () => { const proofs: Proof[] = [ - { id: 'A', amount: 2n, secret: 's1', C: 'C1' }, - { id: 'A', amount: 3n, secret: 's2', C: 'C2' }, + { id: 'A', amount: Amount.from(2), secret: 's1', C: 'C1' }, + { id: 'A', amount: Amount.from(3), secret: 's2', C: 'C2' }, ]; const kc = keychainStub({ A: 0 }); const res = selectProofsRGLI(proofs as any, 10, kc, true, false); @@ -93,25 +109,25 @@ describe('selectProofsRGLI, focused unit tests', () => { test('happy path, succeeds and logs total time, covering final logging lines', () => { const proofs: Proof[] = [ - { id: 'L', amount: 8n, secret: 's1', C: 'C1' }, - { id: 'L', amount: 8n, secret: 's2', C: 'C2' }, - { id: 'L', amount: 4n, secret: 's3', C: 'C3' }, + { id: 'L', amount: Amount.from(8), secret: 's1', C: 'C1' }, + { id: 'L', amount: Amount.from(8), secret: 's2', C: 'C2' }, + { id: 'L', amount: Amount.from(4), secret: 's3', C: 'C3' }, ]; const kc = keychainStub({ L: 600 }); // ceil(600 / 1000) equals one per thousand proofs const { logger, calls } = loggerSpy(); const res = selectProofsRGLI(proofs as any, 15, kc, true, false, logger); - const sum = res.send.reduce((a, p) => a + Number(p.amount), 0); + const sum = sumProofs(res.send); const fee = Math.ceil((res.send.length * 600) / 1000); - expect(sum - fee).toBeGreaterThanOrEqual(15); + expect(sum.subtract(fee).greaterThanOrEqual(15)).toBeTruthy(); expect(calls.info).toHaveBeenCalled(); // covers the info log at the end }); test('local improvement swaps in a better proof and re-inserts the replaced proof in sorted order', () => { const proofs: Proof[] = [ - { id: 'A', amount: 4n, secret: 's4', C: 'C4' }, - { id: 'A', amount: 5n, secret: 's5', C: 'C5' }, - { id: 'A', amount: 6n, secret: 's6', C: 'C6' }, + { id: 'A', amount: Amount.from(4), secret: 's4', C: 'C4' }, + { id: 'A', amount: Amount.from(5), secret: 's5', C: 'C5' }, + { id: 'A', amount: Amount.from(6), secret: 's6', C: 'C6' }, ]; const kc = keychainStub({ A: 0 }); const randomValues = [0.4, 0.9, 0.9]; @@ -120,41 +136,37 @@ describe('selectProofsRGLI, focused unit tests', () => { const res = selectProofsRGLI(proofs as any, 9, kc, false, false); - expect(res.send.map((p) => Number(p.amount)).sort((a, b) => a - b)).toEqual([4, 5]); - expect(res.keep.map((p) => Number(p.amount))).toEqual([6]); + expect(res.send.map((p) => p.amount.toNumber()).sort((a, b) => a - b)).toEqual([4, 5]); + expect(res.keep.map((p) => p.amount.toNumber())).toEqual([6]); }); - test('timeout in exact match throws on time budget exceeded, using module mock and dynamic import', async () => { - vi.mock('../../src/logger', async () => { - const actual = await vi.importActual('../../src/logger'); - return { - ...actual, - measureTime: () => ({ elapsed: () => 10_000 }), // always over budget - }; + test('timeout in exact match throws on time budget exceeded', () => { + let now = 0; + vi.spyOn(Date, 'now').mockImplementation(() => { + now += 10_000; + return now; }); - const { selectProofsRGLI: timed } = await import('../../src/wallet/SelectProofs'); - // Use only even amounts so an odd exact target is impossible. // total = 2 + 4 + 6 + 8 = 20; target = 7 (feasible range, but exact impossible) - const proofs: Proof[] = [ - { id: 'Z', amount: 2n, secret: 's1', C: 'C1' }, - { id: 'Z', amount: 4n, secret: 's2', C: 'C2' }, - { id: 'Z', amount: 6n, secret: 's3', C: 'C3' }, - { id: 'Z', amount: 8n, secret: 's4', C: 'C4' }, + const proofs = [ + { id: 'Z', amount: 2, secret: 's1', C: 'C1' }, + { id: 'Z', amount: 4, secret: 's2', C: 'C2' }, + { id: 'Z', amount: 6, secret: 's3', C: 'C3' }, + { id: 'Z', amount: 8, secret: 's4', C: 'C4' }, ]; const kc = { getKeyset: () => ({ fee: 0 }), getKeysets: () => [{ id: 'Z', fee: 0 }], } as any; - expect(() => timed(proofs as any, 7, kc, false, true)).toThrow(/took too long/i); + expect(() => selectProofsRGLI(proofs as any, 7, kc, false, true)).toThrow(/took too long/i); }); test('throws if keyset fee lookup fails (feeForProof error path)', () => { const proofs: Proof[] = [ - { id: 'MISSING', amount: 4n, secret: 's1', C: 'C1' }, - { id: 'MISSING', amount: 8n, secret: 's2', C: 'C2' }, + { id: 'MISSING', amount: Amount.from(4), secret: 's1', C: 'C1' }, + { id: 'MISSING', amount: Amount.from(8), secret: 's2', C: 'C2' }, ]; // Keychain stub that *throws* for unknown ids diff --git a/test/wallet/wallet-init.node.test.ts b/test/wallet/wallet-init.node.test.ts index 180bfe3e0..3c83c2be9 100644 --- a/test/wallet/wallet-init.node.test.ts +++ b/test/wallet/wallet-init.node.test.ts @@ -509,7 +509,7 @@ describe('test fees', () => { const fee = await wallet.checkMeltQuoteBolt11('test'); const amount = 2000; - expect(fee.fee_reserve.add(amount).toNumber()).toEqual(2020); + expect(fee.fee_reserve.add(amount).equals(2020)).toBeTruthy(); }); }); diff --git a/test/wallet/wallet-melt.node.test.ts b/test/wallet/wallet-melt.node.test.ts index ca54b26ca..cb6df1f06 100644 --- a/test/wallet/wallet-melt.node.test.ts +++ b/test/wallet/wallet-melt.node.test.ts @@ -4,6 +4,7 @@ import { test, describe, expect, vi } from 'vitest'; import { Wallet, type Proof, + type ProofLike, type MeltQuoteBolt11Response, MeltQuoteState, OutputData, @@ -62,13 +63,13 @@ describe('melt proofs', () => { const proofsToSend: Proof[] = [ { id: '00bd033559de27d0', - amount: 8n, + amount: Amount.from(8), secret: 'secret1', C: 'C1', }, { id: '00bd033559de27d0', - amount: 5n, + amount: Amount.from(5), secret: 'secret2', C: 'C2', }, @@ -78,8 +79,8 @@ describe('melt proofs', () => { expect(response.quote.state).toBe(MeltQuoteState.PAID); expect(response.quote.payment_preimage).toBe('preimage'); expect(response.change).toHaveLength(2); - expect(response.change[0]).toMatchObject({ amount: 1n, id: '00bd033559de27d0' }); - expect(response.change[1]).toMatchObject({ amount: 2n, id: '00bd033559de27d0' }); + expect(response.change[0]).toMatchObject({ amount: Amount.from(1), id: '00bd033559de27d0' }); + expect(response.change[1]).toMatchObject({ amount: Amount.from(2), id: '00bd033559de27d0' }); expect(/[0-9a-f]{64}/.test(response.change[0].C)).toBe(true); expect(/[0-9a-f]{64}/.test(response.change[0].secret)).toBe(true); }); @@ -116,13 +117,13 @@ describe('melt proofs', () => { const proofsToSend: Proof[] = [ { id: '00bd033559de27d0', - amount: 8n, + amount: Amount.from(8), secret: 'secret1', C: 'C1', }, { id: '00bd033559de27d0', - amount: 4n, + amount: Amount.from(4), secret: 'secret2', C: 'C2', }, @@ -134,6 +135,57 @@ describe('melt proofs', () => { expect(response.change).toHaveLength(0); }); + test('test melt proofs accepts deserialized ProofLike[] input', async () => { + server.use( + http.post(mintUrl + '/v1/melt/bolt11', () => { + return HttpResponse.json({ + quote: 'test_melt_quote', + amount: 12, + unit: 'sat', + fee_reserve: 0, + state: MeltQuoteState.PAID, + expiry: 1234567890, + payment_preimage: 'preimage', + request: 'bolt11request', + change: [], + }); + }), + ); + const wallet = new Wallet(mint, { unit }); + await wallet.loadMint(); + + const meltQuote: MeltQuoteBolt11Response = { + quote: 'test_melt_quote', + amount: Amount.from(12), + fee_reserve: Amount.from(0), + request: 'bolt11request', + state: MeltQuoteState.UNPAID, + expiry: 1234567890, + payment_preimage: null, + unit: 'sat', + }; + const storedProofs = JSON.parse( + JSON.stringify([ + { + id: '00bd033559de27d0', + amount: Amount.from(8), + secret: 'secret1', + C: 'C1', + }, + { + id: '00bd033559de27d0', + amount: Amount.from(4), + secret: 'secret2', + C: 'C2', + }, + ]), + ) as ProofLike[]; + + const response = await wallet.meltProofsBolt11(meltQuote, storedProofs); + expect(response.quote.state).toBe(MeltQuoteState.PAID); + expect(response.change).toHaveLength(0); + }); + test('test melt proofs pending', async () => { server.use( http.post(mintUrl + '/v1/melt/bolt11', () => { @@ -166,13 +218,13 @@ describe('melt proofs', () => { const proofsToSend: Proof[] = [ { id: '00bd033559de27d0', - amount: 8n, + amount: Amount.from(8), secret: 'secret1', C: 'C1', }, { id: '00bd033559de27d0', - amount: 5n, + amount: Amount.from(5), secret: 'secret2', C: 'C2', }, @@ -190,7 +242,7 @@ describe('melt proofs', () => { const data: OutputData[] = [ new OutputData( { - amount: 0n, + amount: Amount.zero(), B_: '0280999e99569db86fff252e9fe235d5ab0583c5e48e9a6d30b7159ddb2354a664', id: '00bd033559de27d0', }, @@ -204,7 +256,7 @@ describe('melt proofs', () => { ), new OutputData( { - amount: 0n, + amount: Amount.zero(), B_: '0366a12d8f642a9209b2a2b62dd46133d67c61395758760b037526d8ea6ebb0b58', id: '00bd033559de27d0', }, @@ -236,13 +288,13 @@ describe('melt proofs', () => { const proofsToSend: Proof[] = [ { id: '00bd033559de27d0', - amount: 8n, + amount: Amount.from(8), secret: 'secret1', C: 'C1', }, { id: '00bd033559de27d0', - amount: 5n, + amount: Amount.from(5), secret: 'secret2', C: 'C2', }, @@ -279,13 +331,13 @@ describe('melt proofs', () => { const proofsToSend: Proof[] = [ { id: '00bd033559de27d0', - amount: 8n, + amount: Amount.from(8), secret: 'secret1', C: 'C1', }, { id: '00bd033559de27d0', - amount: 5n, + amount: Amount.from(5), secret: 'secret2', C: 'C2', }, @@ -346,13 +398,13 @@ describe('melt proofs', () => { const proofsToSend: Proof[] = [ { id: '00bd033559de27d0', - amount: 8n, + amount: Amount.from(8), secret: 'secret1', C: 'C1', }, { id: '00bd033559de27d0', - amount: 5n, + amount: Amount.from(5), secret: 'secret2', C: 'C2', }, @@ -396,7 +448,7 @@ describe('melt proofs', () => { // response sanity (v3 contract) expect(res.quote.state).toBe(MeltQuoteState.PAID); expect(res.change).toHaveLength(2); - expect(res.change[0]).toMatchObject({ amount: 1n, id: '00bd033559de27d0' }); + expect(res.change[0]).toMatchObject({ amount: Amount.from(1), id: '00bd033559de27d0' }); }); }); @@ -443,13 +495,13 @@ describe('melt proofs', () => { const proofsToSend: Proof[] = [ { id: '00bd033559de27d0', - amount: 8n, + amount: Amount.from(8), secret: 'secret1', C: 'C1', }, { id: '00bd033559de27d0', - amount: 5n, + amount: Amount.from(5), secret: 'secret2', C: 'C2', }, @@ -459,7 +511,7 @@ describe('melt proofs', () => { expect(response.quote.state).toBe(MeltQuoteState.PAID); expect(response.quote.payment_preimage).toBe('preimage'); expect(response.change).toHaveLength(2); - expect(response.change[0]).toMatchObject({ amount: 1n, id: '00bd033559de27d0' }); + expect(response.change[0]).toMatchObject({ amount: Amount.from(1), id: '00bd033559de27d0' }); }); test('mint.meltBolt11 rejects response missing state', async () => { @@ -531,13 +583,13 @@ describe('melt proofs', () => { const proofsToSend: Proof[] = [ { id: '00bd033559de27d0', - amount: 8n, + amount: Amount.from(8), secret: 'secret1', C: 'C1', }, { id: '00bd033559de27d0', - amount: 4n, + amount: Amount.from(4), secret: 'secret2', C: 'C2', }, @@ -561,7 +613,7 @@ describe('async melt preference body', () => { const proofs = [ { id: '00bd033559de27d0', - amount: 1n, + amount: Amount.from(1), secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a13', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', }, @@ -606,7 +658,7 @@ describe('async melt preference body', () => { const proofs = [ { id: '00bd033559de27d0', - amount: 1n, + amount: Amount.from(1), secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a13', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', }, @@ -693,7 +745,7 @@ describe('async melt preference body', () => { const proofs = [ { id: '00bd033559de27d0', - amount: 1n, + amount: Amount.from(1), secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a13', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', }, @@ -754,7 +806,7 @@ describe('async melt preference body', () => { const proofs = [ { id: '00bd033559de27d0', - amount: 1n, + amount: Amount.from(1), secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a13', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', }, diff --git a/test/wallet/wallet-mint.node.test.ts b/test/wallet/wallet-mint.node.test.ts index 1df4e8328..f9205977a 100644 --- a/test/wallet/wallet-mint.node.test.ts +++ b/test/wallet/wallet-mint.node.test.ts @@ -14,7 +14,7 @@ import { AmountLike, } from '../../src'; -import { Bytes } from '../../src/utils'; +import { Bytes, sumProofs } from '../../src/utils'; import { hexToBytes } from '@noble/curves/utils.js'; import { useTestServer, mint, mintUrl, unit, logger, mintInfoResp } from './_setup'; @@ -49,7 +49,7 @@ describe('requestTokens', () => { const proofs = await wallet.mintProofsBolt11(1, mintQuote); expect(proofs).toHaveLength(1); - expect(proofs[0]).toMatchObject({ amount: 1n, id: '00bd033559de27d0' }); + expect(proofs[0]).toMatchObject({ amount: Amount.from(1), id: '00bd033559de27d0' }); expect(/[0-9a-f]{64}/.test(proofs[0].C)).toBe(true); expect(/[0-9a-f]{64}/.test(proofs[0].secret)).toBe(true); }); @@ -91,7 +91,7 @@ describe('requestTokens', () => { expect(mintCalls).toBe(1); expect(proofs).toHaveLength(1); - expect(proofs[0]).toMatchObject({ amount: 1n, id: '00bd033559de27d0' }); + expect(proofs[0]).toMatchObject({ amount: Amount.from(1), id: '00bd033559de27d0' }); }); test('prepareBatchMint consolidates outputs and completeBatchMint sends batch request', async () => { @@ -153,17 +153,14 @@ describe('requestTokens', () => { expect(batchPreview.method).toBe('bolt11'); expect(batchPreview.quotes).toHaveLength(2); // Consolidated outputs: 5+3=8 should produce fewer outputs than separate 5 and 3 - const outputTotal = batchPreview.outputData.reduce( - (sum, o) => sum + BigInt(o.blindedMessage.amount), - 0n, - ); - expect(outputTotal).toBe(8n); + const outputTotal = Amount.sum(batchPreview.outputData.map((o) => o.blindedMessage.amount)); + expect(outputTotal.equals(8)).toBe(true); const proofs = await wallet.completeBatchMint(batchPreview); expect(batchCalls).toBe(1); - const totalAmount = proofs.reduce((sum, p) => sum + p.amount, 0n); - expect(totalAmount).toBe(8n); + const totalAmount = sumProofs(proofs); + expect(totalAmount.equals(8)).toBe(true); expect(proofs.every((p) => p.id === '00bd033559de27d0')).toBe(true); // Verify NUT-20 signatures: first quote has signature, second is null @@ -236,8 +233,8 @@ describe('requestTokens', () => { // Complete the batch to verify full round-trip const proofs = await wallet.completeBatchMint(batchPreview); - const totalAmount = proofs.reduce((sum, p) => sum + p.amount, 0n); - expect(totalAmount).toBe(5n); + const totalAmount = sumProofs(proofs); + expect(totalAmount.equals(5)).toBe(true); }); test('prepareBatchMint omits signatures when all quotes are unlocked', async () => { @@ -284,7 +281,7 @@ describe('requestTokens', () => { expect(batchPreview.payload.signatures).toBeUndefined(); const proofs = await wallet.completeBatchMint(batchPreview); - expect(proofs.reduce((sum, p) => sum + p.amount, 0n)).toBe(5n); + expect(sumProofs(proofs).equals(5)).toBe(true); expect(capturedBody).not.toHaveProperty('signatures'); }); @@ -322,7 +319,7 @@ describe('requestTokens', () => { expect(batchPreview.payload.signatures).toBeUndefined(); const proofs = await wallet.completeBatchMint(batchPreview); - expect(proofs.reduce((sum, p) => sum + p.amount, 0n)).toBe(5n); + expect(sumProofs(proofs).equals(5)).toBe(true); expect(capturedBody).not.toHaveProperty('signatures'); }); @@ -817,7 +814,7 @@ describe('generic mint/melt methods', () => { const proofs = await wallet.mintProofs('bacs', 1, customQuote); expect(proofs).toHaveLength(1); - expect(proofs[0]).toMatchObject({ amount: 1n, id: '00bd033559de27d0' }); + expect(proofs[0]).toMatchObject({ amount: Amount.from(1), id: '00bd033559de27d0' }); }); test('mintProofs rejects quote objects in the wrong wallet unit', async () => { @@ -1007,15 +1004,15 @@ describe('generic mint/melt methods', () => { state: MeltQuoteState.UNPAID, }; const proofsToSend: Proof[] = [ - { id: '00bd033559de27d0', amount: 8n, secret: 'secret1', C: 'C1' }, - { id: '00bd033559de27d0', amount: 5n, secret: 'secret2', C: 'C2' }, + { id: '00bd033559de27d0', amount: Amount.from(8), secret: 'secret1', C: 'C1' }, + { id: '00bd033559de27d0', amount: Amount.from(5), secret: 'secret2', C: 'C2' }, ]; const response = await wallet.meltProofs('bacs', meltQuote, proofsToSend); expect(response.quote.state).toBe(MeltQuoteState.PAID); expect(response.change).toHaveLength(1); - expect(response.change[0]).toMatchObject({ amount: 1n, id: '00bd033559de27d0' }); + expect(response.change[0]).toMatchObject({ amount: Amount.from(1), id: '00bd033559de27d0' }); }); test('meltProofs rejects quote objects in the wrong wallet unit', async () => { @@ -1030,7 +1027,7 @@ describe('generic mint/melt methods', () => { amount: Amount.from(10), unit: 'usd', }, - [{ id: '00bd033559de27d0', amount: 10n, secret: 'secret1', C: 'C1' }], + [{ id: '00bd033559de27d0', amount: Amount.from(10), secret: 'secret1', C: 'C1' }], ), ).rejects.toThrow("Quote unit 'usd' does not match wallet unit 'sat'"); }); diff --git a/test/wallet/wallet-receive.node.test.ts b/test/wallet/wallet-receive.node.test.ts index 61779366a..18a7da521 100644 --- a/test/wallet/wallet-receive.node.test.ts +++ b/test/wallet/wallet-receive.node.test.ts @@ -5,8 +5,10 @@ import { Wallet, getDecodedToken, OutputData, + Amount, type AmountLike, type HasKeysetKeys, + type ProofLike, } from '../../src'; import { hexToBytes } from '@noble/curves/utils.js'; @@ -38,7 +40,7 @@ describe('receive', () => { const proofs = await wallet.receive([ { id: '00bd033559de27d0', - amount: 1n, + amount: Amount.from(1), secret: '407915bc212be61a77e3e6d2aeb4c727980bda51cd06a6afc29e2861768a7837', C: '02bc9097997d81afb2cc7346b5e4345a9346bd2a506eb7958598a72f0cf85163ea', }, @@ -47,6 +49,39 @@ describe('receive', () => { expect(proofs[0].id).toBe('00bd033559de27d0'); }); + test('receive ProofLike[] from JSON.parse - happy path', async () => { + server.use( + http.post(mintUrl + '/v1/swap', () => { + return HttpResponse.json({ + signatures: [ + { + id: '00bd033559de27d0', + amount: 1, + C_: '021179b095a67380ab3285424b563b7aab9818bd38068e1930641b3dceb364d422', + }, + ], + }); + }), + ); + const wallet = new Wallet(mint, { unit }); + await wallet.loadMint(); + + const storedProofs = JSON.parse( + JSON.stringify([ + { + id: '00bd033559de27d0', + amount: Amount.from(1), + secret: '407915bc212be61a77e3e6d2aeb4c727980bda51cd06a6afc29e2861768a7837', + C: '02bc9097997d81afb2cc7346b5e4345a9346bd2a506eb7958598a72f0cf85163ea', + }, + ]), + ) as ProofLike[]; + + const proofs = await wallet.receive(storedProofs); + expect(proofs).toHaveLength(1); + expect(proofs[0].id).toBe('00bd033559de27d0'); + }); + test('receive Proof[] - unknown keyset ID throws', async () => { const wallet = new Wallet(mint, { unit }); await wallet.loadMint(); @@ -55,7 +90,7 @@ describe('receive', () => { wallet.receive([ { id: 'deadbeefdeadbeef', - amount: 1n, + amount: Amount.from(1), secret: '407915bc212be61a77e3e6d2aeb4c727980bda51cd06a6afc29e2861768a7837', C: '02bc9097997d81afb2cc7346b5e4345a9346bd2a506eb7958598a72f0cf85163ea', }, @@ -83,7 +118,7 @@ describe('receive', () => { wallet.receive([ { id: usdKeysetId, - amount: 1n, + amount: Amount.from(1), secret: '407915bc212be61a77e3e6d2aeb4c727980bda51cd06a6afc29e2861768a7837', C: '02bc9097997d81afb2cc7346b5e4345a9346bd2a506eb7958598a72f0cf85163ea', }, @@ -134,7 +169,7 @@ describe('receive', () => { const proofs = await wallet.receive(unsanitizedToken); expect(proofs).toHaveLength(1); - expect(proofs).toMatchObject([{ amount: 1n, id: '00bd033559de27d0' }]); + expect(proofs).toMatchObject([{ amount: Amount.from(1), id: '00bd033559de27d0' }]); expect(/[0-9a-f]{64}/.test(proofs[0].C)).toBe(true); expect(/[0-9a-f]{64}/.test(proofs[0].secret)).toBe(true); }); @@ -159,7 +194,7 @@ describe('receive', () => { const proofs = await wallet.receive(tokenInput); expect(proofs).toHaveLength(1); - expect(proofs).toMatchObject([{ amount: 1n, id: '00bd033559de27d0' }]); + expect(proofs).toMatchObject([{ amount: Amount.from(1), id: '00bd033559de27d0' }]); expect(/[0-9a-f]{64}/.test(proofs[0].C)).toBe(true); expect(/[0-9a-f]{64}/.test(proofs[0].secret)).toBe(true); }); @@ -186,7 +221,7 @@ describe('receive', () => { const proofs = await wallet.receive(decodedInput); expect(proofs).toHaveLength(1); - expect(proofs).toMatchObject([{ amount: 1n, id: 'z32vUtKgNCm1' }]); + expect(proofs).toMatchObject([{ amount: Amount.from(1), id: 'z32vUtKgNCm1' }]); expect(/[0-9a-f]{64}/.test(proofs[0].C)).toBe(true); expect(/[0-9a-f]{64}/.test(proofs[0].secret)).toBe(true); }); @@ -226,9 +261,9 @@ describe('receive', () => { expect(proofs).toHaveLength(3); expect(proofs).toMatchObject([ - { amount: 1n, id: '00bd033559de27d0' }, - { amount: 1n, id: '00bd033559de27d0' }, - { amount: 1n, id: '00bd033559de27d0' }, + { amount: Amount.from(1), id: '00bd033559de27d0' }, + { amount: Amount.from(1), id: '00bd033559de27d0' }, + { amount: Amount.from(1), id: '00bd033559de27d0' }, ]); expect(/[0-9a-f]{64}/.test(proofs[0].C)).toBe(true); expect(/[0-9a-f]{64}/.test(proofs[0].secret)).toBe(true); @@ -297,8 +332,8 @@ describe('receive', () => { const proofs = await wallet.receive(token3sat, {}, { type: 'deterministic', counter: 0 }); expect(proofs).toHaveLength(2); expect(proofs).toMatchObject([ - { amount: 2n, id: '00bd033559de27d0' }, - { amount: 1n, id: '00bd033559de27d0' }, + { amount: Amount.from(2), id: '00bd033559de27d0' }, + { amount: Amount.from(1), id: '00bd033559de27d0' }, ]); expect(proofs[0].secret).toBe( '8e0ad268631046765b570f85fe0951710c6e0e13c81b3df50ddfee21d235d132', // counter:0 @@ -312,8 +347,8 @@ describe('receive', () => { const proofs2 = await wallet.receive(token3sat, {}, { type: 'deterministic', counter: 0 }); expect(proofs2).toHaveLength(2); expect(proofs2).toMatchObject([ - { amount: 2n, id: '00bd033559de27d0' }, - { amount: 1n, id: '00bd033559de27d0' }, + { amount: Amount.from(2), id: '00bd033559de27d0' }, + { amount: Amount.from(1), id: '00bd033559de27d0' }, ]); expect(proofs2[0].secret).toBe( 'c756ae91cf316eaa4b845edcca35f04ee9d1732c10e7205b0ef30123bcbbc1b8', // counter:2 @@ -327,8 +362,8 @@ describe('receive', () => { const proofs3 = await wallet.receive(token3sat, {}, { type: 'deterministic', counter: 0 }); expect(proofs3).toHaveLength(2); expect(proofs3).toMatchObject([ - { amount: 2n, id: '00bd033559de27d0' }, - { amount: 1n, id: '00bd033559de27d0' }, + { amount: Amount.from(2), id: '00bd033559de27d0' }, + { amount: Amount.from(1), id: '00bd033559de27d0' }, ]); expect(proofs3[0].secret).toBe( 'f6305874d89704b77de6fcf94c796cd274154cdbf824d35cbc72bfdc6ed60414', // counter:4 @@ -366,8 +401,8 @@ describe('receive', () => { const proofs = await wallet.receive(token3sat, {}, { type: 'deterministic', counter: 5 }); expect(proofs).toHaveLength(2); expect(proofs).toMatchObject([ - { amount: 2n, id: '00bd033559de27d0' }, - { amount: 1n, id: '00bd033559de27d0' }, + { amount: Amount.from(2), id: '00bd033559de27d0' }, + { amount: Amount.from(1), id: '00bd033559de27d0' }, ]); expect(proofs[0].secret).toBe( 'c3cad2ac3da43f84995a7ea362bd5509a992ef3684c151f5f3945b1a1f026efd', // counter:5 @@ -410,8 +445,8 @@ describe('receive', () => { ); expect(proofs).toHaveLength(2); expect(proofs).toMatchObject([ - { amount: 2n, id: '00bd033559de27d0' }, - { amount: 1n, id: '00bd033559de27d0' }, + { amount: Amount.from(2), id: '00bd033559de27d0' }, + { amount: Amount.from(1), id: '00bd033559de27d0' }, ]); const allSecrets = proofs.map((d) => JSON.parse(d.secret)); allSecrets.forEach((s) => { @@ -450,8 +485,8 @@ describe('receive', () => { const proofs = await wallet.receive(token3sat, {}, { type: 'factory', factory: customFactory }); expect(proofs).toHaveLength(2); expect(proofs).toMatchObject([ - { amount: 2n, id: '00bd033559de27d0' }, - { amount: 1n, id: '00bd033559de27d0' }, + { amount: Amount.from(2), id: '00bd033559de27d0' }, + { amount: Amount.from(1), id: '00bd033559de27d0' }, ]); expect(/[0-9a-f]{64}/.test(proofs[0].C)).toBe(true); expect(/[0-9a-f]{64}/.test(proofs[0].secret)).toBe(true); @@ -492,9 +527,9 @@ describe('receive', () => { const proofs = await wallet.receive(token3sat, {}, { type: 'custom', data: customData }); expect(proofs).toHaveLength(3); expect(proofs).toMatchObject([ - { amount: 1n, id: '00bd033559de27d0' }, - { amount: 1n, id: '00bd033559de27d0' }, - { amount: 1n, id: '00bd033559de27d0' }, + { amount: Amount.from(1), id: '00bd033559de27d0' }, + { amount: Amount.from(1), id: '00bd033559de27d0' }, + { amount: Amount.from(1), id: '00bd033559de27d0' }, ]); expect(/[0-9a-f]{64}/.test(proofs[0].C)).toBe(true); expect(/[0-9a-f]{64}/.test(proofs[0].secret)).toBe(true); @@ -546,28 +581,28 @@ describe('receive', () => { await wallet.loadMint(); const existingProofs = [ - { amount: 2n, id: '00bd033559de27d0', secret: 'test', C: 'test' }, - { amount: 2n, id: '00bd033559de27d0', secret: 'test', C: 'test' }, - { amount: 2n, id: '00bd033559de27d0', secret: 'test', C: 'test' }, + { amount: Amount.from(2), id: '00bd033559de27d0', secret: 'test', C: 'test' }, + { amount: Amount.from(2), id: '00bd033559de27d0', secret: 'test', C: 'test' }, + { amount: Amount.from(2), id: '00bd033559de27d0', secret: 'test', C: 'test' }, ]; const tok = { mint: 'http://localhost:3338', proofs: [ { id: '00bd033559de27d0', - amount: 1n, + amount: Amount.from(1), secret: 'e7c1b76d1b31e2bca2b229d160bdf6046f33bc4570222304b65110d926f7af89', C: '02de40c59d90383b8853ccf3a4b20864ac83ba758fce3d959dbb89361002e8ce47', }, { id: '00bd033559de27d0', - amount: 2n, + amount: Amount.from(2), secret: 'e7c1b76d1b31e2bca2b229d160bdf6046f33bc4570222304b65110d926f7af89', C: '02de40c59d90383b8853ccf3a4b20864ac83ba758fce3d959dbb89361002e8ce47', }, { id: '00bd033559de27d0', - amount: 2n, + amount: Amount.from(2), secret: 'de55c15faefded7f9c999c3d4c62f81b0c6fe21a752fdeff6b084cf2df2f5cf3', C: '02de40c59d90383b8853ccf3a4b20864ac83ba758fce3d959dbb89361002e8ce47', }, @@ -579,10 +614,10 @@ describe('receive', () => { // as we already have the target amount of 2s expect(proofs).toHaveLength(4); expect(proofs).toMatchObject([ - { amount: 1n, id: '00bd033559de27d0' }, - { amount: 1n, id: '00bd033559de27d0' }, - { amount: 1n, id: '00bd033559de27d0' }, - { amount: 2n, id: '00bd033559de27d0' }, + { amount: Amount.from(1), id: '00bd033559de27d0' }, + { amount: Amount.from(1), id: '00bd033559de27d0' }, + { amount: Amount.from(1), id: '00bd033559de27d0' }, + { amount: Amount.from(2), id: '00bd033559de27d0' }, ]); }); @@ -613,8 +648,8 @@ describe('receive', () => { }); expect(proofs).toHaveLength(2); expect(proofs).toMatchObject([ - { amount: 2n, id: '00bd033559de27d0' }, - { amount: 1n, id: '00bd033559de27d0' }, + { amount: Amount.from(2), id: '00bd033559de27d0' }, + { amount: Amount.from(1), id: '00bd033559de27d0' }, ]); expect(/[0-9a-f]{64}/.test(proofs[0].C)).toBe(true); expect(/[0-9a-f]{64}/.test(proofs[0].secret)).toBe(true); @@ -647,8 +682,8 @@ describe('receive', () => { }); expect(proofs).toHaveLength(2); expect(proofs).toMatchObject([ - { amount: 2n, id: '00bd033559de27d0' }, - { amount: 1n, id: '00bd033559de27d0' }, + { amount: Amount.from(2), id: '00bd033559de27d0' }, + { amount: Amount.from(1), id: '00bd033559de27d0' }, ]); expect(/[0-9a-f]{64}/.test(proofs[0].C)).toBe(true); expect(/[0-9a-f]{64}/.test(proofs[0].secret)).toBe(true); diff --git a/test/wallet/wallet-restore.node.test.ts b/test/wallet/wallet-restore.node.test.ts index a76a8578a..51377fcb4 100644 --- a/test/wallet/wallet-restore.node.test.ts +++ b/test/wallet/wallet-restore.node.test.ts @@ -1,7 +1,7 @@ import { HttpResponse, http } from 'msw'; import { test, describe, expect, vi } from 'vitest'; -import { Wallet, type Proof } from '../../src'; +import { Wallet, Amount, type Proof } from '../../src'; import { randomBytes } from '@noble/hashes/utils.js'; import { useTestServer, mint, unit, dummyKeysResp, mintUrl, logger } from './_setup'; @@ -96,6 +96,6 @@ describe('restore', () => { expect(Array.isArray(res.proofs)).toBe(true); expect(res.proofs.length).toBeGreaterThan(0); // proofs should be of amount 1 because we overprinted 1 in the signatures - expect(res.proofs.every((p: any) => p.amount === 1n)).toBe(true); + expect(res.proofs.every((p) => p.amount.equals(Amount.from(1)))).toBe(true); }); }); diff --git a/test/wallet/wallet-send.node.test.ts b/test/wallet/wallet-send.node.test.ts index 04287bece..e5bc95ab4 100644 --- a/test/wallet/wallet-send.node.test.ts +++ b/test/wallet/wallet-send.node.test.ts @@ -1,7 +1,14 @@ import { HttpResponse, http } from 'msw'; import { test, describe, expect } from 'vitest'; -import { Wallet, Amount, OutputData, type Proof, type OutputConfig } from '../../src'; +import { + Wallet, + Amount, + OutputData, + type Proof, + type ProofLike, + type OutputConfig, +} from '../../src'; import { Bytes } from '../../src/utils'; import { hexToBytes } from '@noble/curves/utils.js'; @@ -39,7 +46,7 @@ describe('sendOffline witness normalization', () => { const proofs: Proof[] = [ { id: '00bd033559de27d0', - amount: 1n, + amount: Amount.from(1), secret: plainSecret, C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', witness: JSON.stringify({ signatures: ['deadbeef'] }), @@ -59,7 +66,7 @@ describe('sendOffline witness normalization', () => { const proofs: Proof[] = [ { id: '00bd033559de27d0', - amount: 1n, + amount: Amount.from(1), secret: p2pkSecret, C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', witness: witnessStr, @@ -79,7 +86,7 @@ describe('sendOffline witness normalization', () => { const proofs: Proof[] = [ { id: '00bd033559de27d0', - amount: 1n, + amount: Amount.from(1), secret: p2pkSecret, C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', witness: witnessObj, @@ -98,7 +105,7 @@ describe('sendOffline witness normalization', () => { const proofs: Proof[] = [ { id: '00bd033559de27d0', - amount: 1n, + amount: Amount.from(1), secret: plainSecret, C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', }, @@ -117,7 +124,7 @@ describe('sendOffline witness normalization', () => { const proofs: Proof[] = [ { id: '00bd033559de27d0', - amount: 1n, + amount: Amount.from(1), secret: unknownKindSecret, C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', witness: witnessStr, @@ -137,14 +144,14 @@ describe('sendOffline witness normalization', () => { const proofs: Proof[] = [ { id: '00bd033559de27d0', - amount: 1n, + amount: Amount.from(1), secret: plainSecret, C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', witness: JSON.stringify({ signatures: ['deadbeef'] }), }, { id: '00bd033559de27d0', - amount: 1n, + amount: Amount.from(1), secret: p2pkSecret, C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', witness: witnessStr, @@ -160,13 +167,33 @@ describe('sendOffline witness normalization', () => { const p2pk = send.find((p) => p.secret === p2pkSecret)!; expect(p2pk.witness).toBe(witnessStr); }); + + test('accepts deserialized ProofLike[] input', async () => { + const wallet = new Wallet(mint, { unit }); + await wallet.loadMint(); + + const storedProofs = JSON.parse( + JSON.stringify([ + { + id: '00bd033559de27d0', + amount: Amount.from(1), + secret: plainSecret, + C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', + }, + ]), + ) as ProofLike[]; + + const { send } = wallet.sendOffline(1, storedProofs); + expect(send).toHaveLength(1); + expect(send[0].amount).toEqual(Amount.from(1)); + }); }); describe('send', () => { const proofs = [ { id: '00bd033559de27d0', - amount: 1n, + amount: Amount.from(1), secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a13', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', }, @@ -192,7 +219,7 @@ describe('send', () => { expect(result.keep).toHaveLength(0); expect(result.send).toHaveLength(1); - expect(result.send[0]).toMatchObject({ amount: 1n, id: '00bd033559de27d0' }); + expect(result.send[0]).toMatchObject({ amount: Amount.from(1), id: '00bd033559de27d0' }); expect(/[0-9a-f]{64}/.test(result.send[0].C)).toBe(true); expect(/[0-9a-f]{64}/.test(result.send[0].secret)).toBe(true); }); @@ -206,10 +233,22 @@ describe('send', () => { const result = await wallet.send(amount, proofs); expect(result.keep).toHaveLength(0); expect(result.send).toHaveLength(1); - expect(result.send[0]).toMatchObject({ amount: 1n, id: '00bd033559de27d0' }); + expect(result.send[0]).toMatchObject({ amount: Amount.from(1), id: '00bd033559de27d0' }); } }); + test('test send accepts deserialized ProofLike[] input', async () => { + const wallet = new Wallet(mint, { unit }); + await wallet.loadMint(); + + const storedProofs = JSON.parse(JSON.stringify(proofs)) as ProofLike[]; + const result = await wallet.send(1, storedProofs); + + expect(result.keep).toHaveLength(0); + expect(result.send).toHaveLength(1); + expect(result.send[0]).toMatchObject({ amount: Amount.from(1), id: '00bd033559de27d0' }); + }); + test('test send over paying. Should return change', async () => { server.use( http.post(mintUrl + '/v1/swap', () => { @@ -235,18 +274,18 @@ describe('send', () => { const result = await wallet.send(1, [ { id: '00bd033559de27d0', - amount: 2n, + amount: Amount.from(2), secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a13', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', }, ]); expect(result.send).toHaveLength(1); - expect(result.send[0]).toMatchObject({ amount: 1n, id: '00bd033559de27d0' }); + expect(result.send[0]).toMatchObject({ amount: Amount.from(1), id: '00bd033559de27d0' }); expect(/[0-9a-f]{64}/.test(result.send[0].C)).toBe(true); expect(/[0-9a-f]{64}/.test(result.send[0].secret)).toBe(true); expect(result.keep).toHaveLength(1); - expect(result.keep[0]).toMatchObject({ amount: 1n, id: '00bd033559de27d0' }); + expect(result.keep[0]).toMatchObject({ amount: Amount.from(1), id: '00bd033559de27d0' }); expect(/[0-9a-f]{64}/.test(result.keep[0].C)).toBe(true); expect(/[0-9a-f]{64}/.test(result.keep[0].secret)).toBe(true); }); @@ -277,7 +316,7 @@ describe('send', () => { [ { id: '00bd033559de27d0', - amount: 2n, + amount: Amount.from(2), secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a13', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', }, @@ -319,7 +358,7 @@ describe('send', () => { const overpayProofs = [ { id: '00bd033559de27d0', - amount: 2n, + amount: Amount.from(2), secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a13', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', }, @@ -327,11 +366,11 @@ describe('send', () => { const result = await wallet.send(1, overpayProofs); expect(result.send).toHaveLength(1); - expect(result.send[0]).toMatchObject({ amount: 1n, id: '00bd033559de27d0' }); + expect(result.send[0]).toMatchObject({ amount: Amount.from(1), id: '00bd033559de27d0' }); expect(/[0-9a-f]{64}/.test(result.send[0].C)).toBe(true); expect(/[0-9a-f]{64}/.test(result.send[0].secret)).toBe(true); expect(result.keep).toHaveLength(1); - expect(result.keep[0]).toMatchObject({ amount: 1n, id: '00bd033559de27d0' }); + expect(result.keep[0]).toMatchObject({ amount: Amount.from(1), id: '00bd033559de27d0' }); expect(/[0-9a-f]{64}/.test(result.keep[0].C)).toBe(true); expect(/[0-9a-f]{64}/.test(result.keep[0].secret)).toBe(true); }); @@ -370,13 +409,13 @@ describe('send', () => { const overpayProofs = [ { id: '00bd033559de27d0', - amount: 2n, + amount: Amount.from(2), secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a13', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', }, { id: '00bd033559de27d0', - amount: 2n, + amount: Amount.from(2), secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a13', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', }, @@ -395,10 +434,10 @@ describe('send', () => { ); expect(result.send).toHaveLength(4); - expect(result.send[0]).toMatchObject({ amount: 1n, id: '00bd033559de27d0' }); - expect(result.send[1]).toMatchObject({ amount: 1n, id: '00bd033559de27d0' }); - expect(result.send[2]).toMatchObject({ amount: 1n, id: '00bd033559de27d0' }); - expect(result.send[3]).toMatchObject({ amount: 1n, id: '00bd033559de27d0' }); + expect(result.send[0]).toMatchObject({ amount: Amount.from(1), id: '00bd033559de27d0' }); + expect(result.send[1]).toMatchObject({ amount: Amount.from(1), id: '00bd033559de27d0' }); + expect(result.send[2]).toMatchObject({ amount: Amount.from(1), id: '00bd033559de27d0' }); + expect(result.send[3]).toMatchObject({ amount: Amount.from(1), id: '00bd033559de27d0' }); expect(/[0-9a-f]{64}/.test(result.send[0].C)).toBe(true); expect(/[0-9a-f]{64}/.test(result.send[0].secret)).toBe(true); expect(result.keep).toHaveLength(0); @@ -439,13 +478,13 @@ describe('send', () => { const overpayProofs = [ { id: '00bd033559de27d0', - amount: 2n, + amount: Amount.from(2), secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a13', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', }, { id: '00bd033559de27d0', - amount: 2n, + amount: Amount.from(2), secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a13', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', }, @@ -461,13 +500,13 @@ describe('send', () => { ); expect(result.send).toHaveLength(3); - expect(result.send[0]).toMatchObject({ amount: 1n, id: '00bd033559de27d0' }); - expect(result.send[1]).toMatchObject({ amount: 1n, id: '00bd033559de27d0' }); - expect(result.send[2]).toMatchObject({ amount: 1n, id: '00bd033559de27d0' }); + expect(result.send[0]).toMatchObject({ amount: Amount.from(1), id: '00bd033559de27d0' }); + expect(result.send[1]).toMatchObject({ amount: Amount.from(1), id: '00bd033559de27d0' }); + expect(result.send[2]).toMatchObject({ amount: Amount.from(1), id: '00bd033559de27d0' }); expect(/[0-9a-f]{64}/.test(result.send[0].C)).toBe(true); expect(/[0-9a-f]{64}/.test(result.send[0].secret)).toBe(true); expect(result.keep).toHaveLength(1); - expect(result.keep[0]).toMatchObject({ amount: 1n, id: '00bd033559de27d0' }); + expect(result.keep[0]).toMatchObject({ amount: Amount.from(1), id: '00bd033559de27d0' }); }); test('test send not enough funds', async () => { @@ -503,7 +542,7 @@ describe('send', () => { wallet.send(1, [ { id: '00bd033559de27d0', - amount: 2n, + amount: Amount.from(2), secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a13', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', }, @@ -557,20 +596,20 @@ describe('send', () => { const overpayProofs = [ { id: '00bd033559de27d0', - amount: 1n, + amount: Amount.from(1), secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a13', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', }, { id: '00bd033559de27d0', - amount: 8n, + amount: Amount.from(8), secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a13', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', }, ]; const result = await wallet.send(3, overpayProofs, { includeFees: true, - proofsWeHave: [{ secret: '123', C: '123', amount: 64n, id: 'id' } as Proof], + proofsWeHave: [{ secret: '123', C: '123', amount: Amount.from(64), id: 'id' } as Proof], }); // Swap 8, get 7 back (after 1*600ppk = 1 sat fee). @@ -580,15 +619,15 @@ describe('send', () => { // Total change = [1, 1] because proofs are optimized to target (3) // Total keep = [1, 1, 1] expect(result.send).toHaveLength(3); - expect(result.send[0]).toMatchObject({ amount: 2n, id: '00bd033559de27d0' }); - expect(result.send[1]).toMatchObject({ amount: 1n, id: '00bd033559de27d0' }); - expect(result.send[2]).toMatchObject({ amount: 2n, id: '00bd033559de27d0' }); + expect(result.send[0]).toMatchObject({ amount: Amount.from(2), id: '00bd033559de27d0' }); + expect(result.send[1]).toMatchObject({ amount: Amount.from(1), id: '00bd033559de27d0' }); + expect(result.send[2]).toMatchObject({ amount: Amount.from(2), id: '00bd033559de27d0' }); expect(/[0-9a-f]{64}/.test(result.send[0].C)).toBe(true); expect(/[0-9a-f]{64}/.test(result.send[0].secret)).toBe(true); expect(result.keep).toHaveLength(3); - expect(result.keep[0]).toMatchObject({ amount: 1n, id: '00bd033559de27d0' }); - expect(result.keep[0]).toMatchObject({ amount: 1n, id: '00bd033559de27d0' }); - expect(result.keep[1]).toMatchObject({ amount: 1n, id: '00bd033559de27d0' }); + expect(result.keep[0]).toMatchObject({ amount: Amount.from(1), id: '00bd033559de27d0' }); + expect(result.keep[0]).toMatchObject({ amount: Amount.from(1), id: '00bd033559de27d0' }); + expect(result.keep[1]).toMatchObject({ amount: Amount.from(1), id: '00bd033559de27d0' }); }); test('test send preference with fees included', async () => { @@ -633,13 +672,13 @@ describe('send', () => { const overpayProofs = [ { id: '00bd033559de27d0', - amount: 1n, + amount: Amount.from(1), secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a13', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', }, { id: '00bd033559de27d0', - amount: 8n, + amount: Amount.from(8), secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a13', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', }, @@ -653,14 +692,14 @@ describe('send', () => { // Total change = [2] because proofs are not optimized // Total keep = [2, 1] expect(result.send).toHaveLength(3); - expect(result.send[0]).toMatchObject({ amount: 2n, id: '00bd033559de27d0' }); - expect(result.send[1]).toMatchObject({ amount: 1n, id: '00bd033559de27d0' }); - expect(result.send[2]).toMatchObject({ amount: 2n, id: '00bd033559de27d0' }); + expect(result.send[0]).toMatchObject({ amount: Amount.from(2), id: '00bd033559de27d0' }); + expect(result.send[1]).toMatchObject({ amount: Amount.from(1), id: '00bd033559de27d0' }); + expect(result.send[2]).toMatchObject({ amount: Amount.from(2), id: '00bd033559de27d0' }); expect(/[0-9a-f]{64}/.test(result.send[0].C)).toBe(true); expect(/[0-9a-f]{64}/.test(result.send[0].secret)).toBe(true); expect(result.keep).toHaveLength(2); - expect(result.keep[0]).toMatchObject({ amount: 2n, id: '00bd033559de27d0' }); - expect(result.keep[1]).toMatchObject({ amount: 1n, id: '00bd033559de27d0' }); + expect(result.keep[0]).toMatchObject({ amount: Amount.from(2), id: '00bd033559de27d0' }); + expect(result.keep[1]).toMatchObject({ amount: Amount.from(1), id: '00bd033559de27d0' }); }); test('send with deterministic keep/send auto-offsets counters and fees', async () => { server.use( @@ -693,13 +732,13 @@ describe('send', () => { const overpayProofs = [ { id: '00bd033559de27d0', - amount: 1n, + amount: Amount.from(1), secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a13', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', }, { id: '00bd033559de27d0', - amount: 8n, + amount: Amount.from(8), secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a13', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', }, @@ -732,13 +771,13 @@ describe('send', () => { const proofs = [ { id: keysetId, - amount: 1n, + amount: Amount.from(1), secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a13', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', }, { id: keysetId, - amount: 8n, + amount: Amount.from(8), secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a13', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', }, @@ -861,13 +900,13 @@ describe('send', () => { const proofs = [ { id: keysetId, - amount: 1n, + amount: Amount.from(1), secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a13', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', }, { id: keysetId, - amount: 8n, + amount: Amount.from(8), secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a13', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', }, @@ -882,6 +921,32 @@ describe('send', () => { wallet.prepareSwapToSend(3, proofs, { includeFees: true }, outputConfig), ).rejects.toThrow('Manual counter ranges overlap'); }); + + test('prepareSwapToSend accepts deserialized ProofLike[] input', async () => { + const wallet = new Wallet(mint, { unit }); + await wallet.loadMint(); + + const storedProofs = JSON.parse( + JSON.stringify([ + { + id: '00bd033559de27d0', + amount: Amount.from(1), + secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a13', + C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', + }, + { + id: '00bd033559de27d0', + amount: Amount.from(8), + secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a13', + C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', + }, + ]), + ) as ProofLike[]; + + const res = await wallet.prepareSwapToSend(3, storedProofs, { includeFees: true }); + expect(res.inputs.length).toBeGreaterThan(0); + expect(res.inputs.every((p) => p.amount instanceof Amount)).toBe(true); + }); test('manual counters advances cursor, then auto allocation must not reuse counters', async () => { server.use( http.get(mintUrl + '/v1/keysets', () => { @@ -899,13 +964,13 @@ describe('send', () => { const proofs = [ { id: keysetId, - amount: 1n, + amount: Amount.from(1), secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a13', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', }, { id: keysetId, - amount: 8n, + amount: Amount.from(8), secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a13', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', }, @@ -971,7 +1036,7 @@ describe('deterministic', () => { [ { id: '00bd033559de27d0', - amount: 2n, + amount: Amount.from(2), secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a13', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', }, diff --git a/test/wallet/wallet-state.node.test.ts b/test/wallet/wallet-state.node.test.ts index 251c8b3d3..129f94427 100644 --- a/test/wallet/wallet-state.node.test.ts +++ b/test/wallet/wallet-state.node.test.ts @@ -1,6 +1,6 @@ import { HttpResponse, http } from 'msw'; import { test, describe, expect } from 'vitest'; -import { Wallet, CheckStateEnum } from '../../src'; +import { Wallet, CheckStateEnum, Amount } from '../../src'; import { mint, unit, mintUrl, useTestServer } from './_setup'; const server = useTestServer(); @@ -44,37 +44,37 @@ describe('groupProofsByState', () => { const proofs = [ { id: '00bd033559de27d0', - amount: 2n, + amount: Amount.from(2), secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a13', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', }, { id: '00bd033559de27d0', - amount: 8n, + amount: Amount.from(8), secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a14', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', }, { id: '00bd033559de27d0', - amount: 128n, + amount: Amount.from(128), secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a15', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', }, { id: '00bd033559de27d0', - amount: 4n, + amount: Amount.from(4), secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a16', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', }, { id: '00bd033559de27d0', - amount: 1n, + amount: Amount.from(1), secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a17', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', }, { id: '00bd033559de27d0', - amount: 16n, + amount: Amount.from(16), secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a18', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be', }, @@ -120,11 +120,11 @@ describe('groupProofsByState', () => { const wallet = new Wallet(mint, { unit }); await wallet.loadMint(); const result = await wallet.groupProofsByState(proofs); - expect(result.unspent[0].amount).toEqual(8n); - expect(result.unspent[1].amount).toEqual(4n); - expect(result.spent[0].amount).toEqual(2n); - expect(result.spent[1].amount).toEqual(128n); - expect(result.spent[2].amount).toEqual(16n); - expect(result.pending[0].amount).toEqual(1n); + expect(result.unspent[0].amount.equals(8n)).toBeTruthy(); + expect(result.unspent[1].amount.equals(4n)).toBeTruthy(); + expect(result.spent[0].amount.equals(2n)).toBeTruthy(); + expect(result.spent[1].amount.equals(128n)).toBeTruthy(); + expect(result.spent[2].amount.equals(16n)).toBeTruthy(); + expect(result.pending[0].amount.equals(1n)).toBeTruthy(); }); });