diff --git a/.github/workflows/nutshell-integration.yml b/.github/workflows/nutshell-integration.yml index 94ccf849..3e881511 100644 --- a/.github/workflows/nutshell-integration.yml +++ b/.github/workflows/nutshell-integration.yml @@ -8,7 +8,7 @@ jobs: steps: - name: Pull and start mint run: | - docker run -d -p 3338:3338 --name nutshell -e MINT_LIGHTNING_BACKEND=FakeWallet -e MINT_LISTEN_HOST=0.0.0.0 -e MINT_LISTEN_PORT=3338 -e MINT_PRIVATE_KEY=TEST_PRIVATE_KEY cashubtc/nutshell:0.16.0 poetry run mint + docker run -d -p 3338:3338 --name nutshell -e MINT_LIGHTNING_BACKEND=FakeWallet -e MINT_INPUT_FEE_PPK=100 -e MINT_LISTEN_HOST=0.0.0.0 -e MINT_LISTEN_PORT=3338 -e MINT_PRIVATE_KEY=TEST_PRIVATE_KEY cashubtc/nutshell:0.16.0 poetry run mint - name: Check running containers run: docker ps diff --git a/README.md b/README.md index 1e1e6ca9..1fda2f75 100644 --- a/README.md +++ b/README.md @@ -63,11 +63,12 @@ import { CashuMint, CashuWallet, MintQuoteState } from '@cashu/cashu-ts'; const mintUrl = 'http://localhost:3338'; // the mint URL const mint = new CashuMint(mintUrl); const wallet = new CashuWallet(mint); +await wallet.loadMint(); // persist wallet.keys and wallet.keysets to avoid calling loadMint() in the future const mintQuote = await wallet.createMintQuote(64); // pay the invoice here before you continue... const mintQuoteChecked = await wallet.checkMintQuote(mintQuote.quote); if (mintQuoteChecked.state == MintQuoteState.PAID) { - const { proofs } = await wallet.mintTokens(64, mintQuote.quote); + const { proofs } = await wallet.mintProofs(64, mintQuote.quote); } ``` @@ -77,21 +78,36 @@ if (mintQuoteChecked.state == MintQuoteState.PAID) { import { CashuMint, CashuWallet } from '@cashu/cashu-ts'; const mintUrl = 'http://localhost:3338'; // the mint URL const mint = new CashuMint(mintUrl); -const wallet = new CashuWallet(mint); +const wallet = new CashuWallet(mint); // load the keysets of the mint const invoice = 'lnbc......'; // Lightning invoice to pay const meltQuote = await wallet.createMeltQuote(invoice); const amountToSend = meltQuote.amount + meltQuote.fee_reserve; -// in a real wallet, we would coin select the correct amount of proofs from the wallet's storage -// instead of that, here we swap `proofs` with the mint to get the correct amount of proofs -const { returnChange: proofsToKeep, send: proofsToSend } = await wallet.send(amountToSend, proofs); +// CashuWallet.send performs coin selection and swaps the proofs with the mint +// if no appropriate amount can be selected offline. We must include potential +// ecash fees that the mint might require to melt the resulting proofsToSend later. +const { keep: proofsToKeep, send: proofsToSend } = await wallet.send(amountToSend, proofs, { + includeFees: true +}); // store proofsToKeep in wallet .. -const meltResponse = await wallet.meltTokens(meltQuote, proofsToSend); +const meltResponse = await wallet.meltProofs(meltQuote, proofsToSend); // store meltResponse.change in wallet .. ``` +#### Create a token and receive it + +```typescript +// we assume that `wallet` already minted `proofs`, as above +const { keep, send } = await wallet.send(32, proofs); +const token = getEncodedTokenV4({ token: [{ mint: mintUrl, proofs: send }] }); +console.log(token); + +const wallet2 = new CashuWallet(mint); // receiving wallet +const receiveProofs = await wallet2.receive(token); +``` + ## Contribute Contributions are very welcome. diff --git a/migration-2.0.0.md b/migration-2.0.0.md new file mode 100644 index 00000000..a4b30c1f --- /dev/null +++ b/migration-2.0.0.md @@ -0,0 +1,42 @@ +# Version 2.0.0 Migration guide + +⚠️ Upgrading to version 2.0.0 will come with breaking changes! Please follow the migration guide for a smooth transition to the new version. + +## Breaking changes + +### `CashuWallet` interface changes + +#### removed `payLnInvoice` helper + +The helper function was removed. Instead users will have to manage a melt quote manually: + +```ts +const quote = await wallet.createMeltQuote(invoice); +const totalAmount = quote.fee_reserve + invoiceAmount; +const { keep, send } = await wallet.send(totalAmount, proofs); +const payRes = await wallet.meltProofs(quote, send); +``` + +--- + +#### Preference for outputs are now passed as a object of simple arrays + +**`AmountPreference`** is not used anymore. + +`preference?: Array;` -> `outputAmounts?: OutputAmounts;` + +where + +```typescript +export type OutputAmounts = { + sendAmounts: Array; + keepAmounts?: Array; +}; +``` + +#### renamed functions + +- in `SendResponse`, `returnChange` is now called `keep` +- `CashuWallet.mintTokens()` is now called `CashuWallet.mintProofs()` +- `CashuWallet.meltTokens()` is now called `CashuWallet.meltProofs()` +- `CashuMint.split()` is now called `CashuMint.swap()` diff --git a/src/CashuMint.ts b/src/CashuMint.ts index 8df76b83..b86a07eb 100644 --- a/src/CashuMint.ts +++ b/src/CashuMint.ts @@ -76,7 +76,7 @@ class CashuMint { * @param customRequest * @returns signed outputs */ - public static async split( + public static async swap( mintUrl: string, swapPayload: SwapPayload, customRequest?: typeof request @@ -99,8 +99,8 @@ class CashuMint { * @param swapPayload payload containing inputs and outputs * @returns signed outputs */ - async split(swapPayload: SwapPayload): Promise { - return CashuMint.split(this._mintUrl, swapPayload, this._customRequest); + async swap(swapPayload: SwapPayload): Promise { + return CashuMint.swap(this._mintUrl, swapPayload, this._customRequest); } /** diff --git a/src/CashuWallet.ts b/src/CashuWallet.ts index 6f8280ce..b5e5d97e 100644 --- a/src/CashuWallet.ts +++ b/src/CashuWallet.ts @@ -2,13 +2,13 @@ import { bytesToHex, randomBytes } from '@noble/hashes/utils'; import { CashuMint } from './CashuMint.js'; import { BlindedMessage } from './model/BlindedMessage.js'; import { - type AmountPreference, type BlindedMessageData, type BlindedTransaction, type MeltPayload, type MeltQuoteResponse, type MintKeys, - type MeltTokensResponse, + type MintKeyset, + type MeltProofsResponse, type MintPayload, type Proof, type MintQuotePayload, @@ -20,17 +20,11 @@ import { type TokenEntry, CheckStateEnum, SerializedBlindedSignature, - MeltQuoteState, - CheckStateEntry, - Preferences + GetInfoResponse, + OutputAmounts, + CheckStateEntry } from './model/types/index.js'; -import { - bytesToNumber, - getDecodedToken, - getDefaultAmountPreference, - splitAmount -} from './utils.js'; -import { isAmountPreferenceArray, deprecatedAmountPreferences } from './legacy/cashu-ts'; +import { bytesToNumber, getDecodedToken, splitAmount, sumProofs, getKeepAmounts } from './utils.js'; import { validateMnemonic } from '@scure/bip39'; import { wordlist } from '@scure/bip39/wordlists/english'; import { hashToCurve, pointFromHex } from '@cashu/crypto/modules/common'; @@ -47,87 +41,221 @@ import { import { createP2PKsecret, getSignedProofs } from '@cashu/crypto/modules/client/NUT11'; import { type Proof as NUT11Proof } from '@cashu/crypto/modules/common/index'; +/** + * The default number of proofs per denomination to keep in a wallet. + */ +const DEFAULT_DENOMINATION_TARGET = 3; + +/** + * The default unit for the wallet, if not specified in constructor. + */ +const DEFAULT_UNIT = 'sat'; + /** * Class that represents a Cashu wallet. * This class should act as the entry point for this library */ class CashuWallet { - private _keys: MintKeys | undefined; - private _seed: Uint8Array | undefined; - private _unit = 'sat'; + private _keys: Map = new Map(); + private _keysetId: string | undefined; + private _keysets: Array = []; + private _seed: Uint8Array | undefined = undefined; + private _unit = DEFAULT_UNIT; + private _mintInfo: GetInfoResponse | undefined = undefined; + private _denominationTarget = DEFAULT_DENOMINATION_TARGET; + mint: CashuMint; /** - * @param unit optionally set unit - * @param keys public keys from the mint. If set, it will override the unit with the keysets unit * @param mint Cashu mint instance is used to make api calls - * @param mnemonicOrSeed mnemonic phrase or Seed to initial derivation key for this wallets deterministic secrets. When the mnemonic is provided, the seed will be derived from it. + * @param options.unit optionally set unit (default is 'sat') + * @param options.keys public keys from the mint (will be fetched from mint if not provided) + * @param options.keysets keysets from the mint (will be fetched from mint if not provided) + * @param options.mintInfo mint info from the mint (will be fetched from mint if not provided) + * @param options.denominationTarget target number proofs per denomination (default: see @constant DEFAULT_DENOMINATION_TARGET) + * @param options.mnemonicOrSeed mnemonic phrase or Seed to initial derivation key for this wallet's deterministic secrets. When the mnemonic is provided, the seed will be derived from it. * This can lead to poor performance, in which case the seed should be directly provided */ constructor( mint: CashuMint, options?: { unit?: string; - keys?: MintKeys; + keys?: Array | MintKeys; + keysets?: Array; + mintInfo?: GetInfoResponse; mnemonicOrSeed?: string | Uint8Array; + denominationTarget?: number; } ) { this.mint = mint; + let keys: Array = []; + if (options?.keys && !Array.isArray(options.keys)) { + keys = [options.keys]; + } else if (options?.keys && Array.isArray(options?.keys)) { + keys = options?.keys; + } + if (keys) keys.forEach((key: MintKeys) => this._keys.set(key.id, key)); if (options?.unit) this._unit = options?.unit; - if (options?.keys) { - this._keys = options.keys; - this._unit = options.keys.unit; + if (options?.keysets) this._keysets = options.keysets; + if (options?.denominationTarget) { + this._denominationTarget = options.denominationTarget; } + if (!options?.mnemonicOrSeed) { return; - } - if (options?.mnemonicOrSeed instanceof Uint8Array) { + } else if (options?.mnemonicOrSeed instanceof Uint8Array) { this._seed = options.mnemonicOrSeed; - return; - } - if (!validateMnemonic(options.mnemonicOrSeed, wordlist)) { - throw new Error('Tried to instantiate with mnemonic, but mnemonic was invalid'); + } else { + if (!validateMnemonic(options.mnemonicOrSeed, wordlist)) { + throw new Error('Tried to instantiate with mnemonic, but mnemonic was invalid'); + } + this._seed = deriveSeedFromMnemonic(options.mnemonicOrSeed); } - this._seed = deriveSeedFromMnemonic(options.mnemonicOrSeed); } get unit(): string { return this._unit; } - - get keys(): MintKeys { - if (!this._keys) { - throw new Error('Keys are not set'); - } + get keys(): Map { return this._keys; } - set keys(keys: MintKeys) { - this._keys = keys; - this._unit = keys.unit; + get keysetId(): string { + if (!this._keysetId) { + throw new Error('No keysetId set'); + } + return this._keysetId; + } + set keysetId(keysetId: string) { + this._keysetId = keysetId; + } + get keysets(): Array { + return this._keysets; + } + get mintInfo(): GetInfoResponse { + if (!this._mintInfo) { + throw new Error('Mint info not loaded'); + } + return this._mintInfo; } /** * Get information about the mint * @returns mint info */ - async getMintInfo() { - return this.mint.getInfo(); + async getMintInfo(): Promise { + this._mintInfo = await this.mint.getInfo(); + return this._mintInfo; + } + + /** + * Load mint information, keysets and keys. This function can be called if no keysets are passed in the constructor + */ + async loadMint() { + await this.getMintInfo(); + await this.getKeySets(); + await this.getKeys(); + } + + /** + * Choose a keyset to activate based on the lowest input fee + * + * Note: this function will filter out deprecated base64 keysets + * + * @param keysets keysets to choose from + * @returns active keyset + */ + getActiveKeyset(keysets: Array): MintKeyset { + let activeKeysets = keysets.filter((k: MintKeyset) => k.active); + + // we only consider keyset IDs that start with "00" + activeKeysets = activeKeysets.filter((k: MintKeyset) => k.id.startsWith('00')); + + const activeKeyset = activeKeysets.sort( + (a: MintKeyset, b: MintKeyset) => (a.input_fee_ppk ?? 0) - (b.input_fee_ppk ?? 0) + )[0]; + if (!activeKeyset) { + throw new Error('No active keyset found'); + } + return activeKeyset; + } + + /** + * Get keysets from the mint with the unit of the wallet + * @returns keysets with wallet's unit + */ + async getKeySets(): Promise> { + const allKeysets = await this.mint.getKeySets(); + const unitKeysets = allKeysets.keysets.filter((k: MintKeyset) => k.unit === this._unit); + this._keysets = unitKeysets; + return this._keysets; + } + + /** + * Get all active keys from the mint and set the keyset with the lowest fees as the active wallet keyset. + * @returns keyset + */ + async getAllKeys(): Promise> { + const keysets = await this.mint.getKeys(); + this._keys = new Map(keysets.keysets.map((k: MintKeys) => [k.id, k])); + this.keysetId = this.getActiveKeyset(this._keysets).id; + return keysets.keysets; + } + + /** + * Get public keys from the mint. If keys were already fetched, it will return those. + * + * If `keysetId` is set, it will fetch and return that specific keyset. + * Otherwise, we select an active keyset with the unit of the wallet. + * + * @param keysetId optional keysetId to get keys for + * @param forceRefresh? if set to true, it will force refresh the keyset from the mint + * @returns keyset + */ + async getKeys(keysetId?: string, forceRefresh?: boolean): Promise { + if (!(this._keysets.length > 0) || forceRefresh) { + await this.getKeySets(); + } + // no keyset id is chosen, let's choose one + if (!keysetId) { + const localKeyset = this.getActiveKeyset(this._keysets); + keysetId = localKeyset.id; + } + // make sure we have keyset for this id + if (!this._keysets.find((k: MintKeyset) => k.id === keysetId)) { + await this.getKeySets(); + if (!this._keysets.find((k: MintKeyset) => k.id === keysetId)) { + throw new Error(`could not initialize keys. No keyset with id '${keysetId}' found`); + } + } + + // make sure we have keys for this id + if (!this._keys.get(keysetId)) { + const keys = await this.mint.getKeys(keysetId); + this._keys.set(keysetId, keys.keysets[0]); + } + + // set and return + this.keysetId = keysetId; + return this._keys.get(keysetId) as MintKeys; } /** * Receive an encoded or raw Cashu token (only supports single tokens. It will only process the first token in the token array) - * @param {(string|Token)} token - Cashu token - * @param preference optional preference for splitting proofs into specific amounts - * @param counter? optionally set counter to derive secret deterministically. CashuWallet class must be initialized with seed phrase to take effect - * @param pubkey? optionally locks ecash to pubkey. Will not be deterministic, even if counter is set! - * @param privkey? will create a signature on the @param token secrets if set + * @param {(string|Token)} token - Cashu token, either as string or decoded + * @param options.keysetId? override the keysetId derived from the current mintKeys with a custom one. This should be a keyset that was fetched from the `/keysets` endpoint + * @param options.outputAmounts? optionally specify the output's amounts to keep and to send. + * @param options.proofsWeHave? optionally provide all currently stored proofs of this mint. Cashu-ts will use them to derive the optimal output amounts + * @param options.counter? optionally set counter to derive secret deterministically. CashuWallet class must be initialized with seed phrase to take effect + * @param options.pubkey? optionally locks ecash to pubkey. Will not be deterministic, even if counter is set! + * @param options.privkey? will create a signature on the @param token secrets if set * @returns New token with newly created proofs, token entries that had errors */ async receive( token: string | Token, options?: { keysetId?: string; - preference?: Array; + outputAmounts?: OutputAmounts; + proofsWeHave?: Array; counter?: number; pubkey?: string; privkey?: string; @@ -137,53 +265,45 @@ class CashuWallet { token = getDecodedToken(token); } const tokenEntries: Array = token.token; - const proofs = await this.receiveTokenEntry(tokenEntries[0], { - keysetId: options?.keysetId, - preference: options?.preference, - counter: options?.counter, - pubkey: options?.pubkey, - privkey: options?.privkey - }); + const proofs = await this.receiveTokenEntry(tokenEntries[0], options); return proofs; } /** * Receive a single cashu token entry * @param tokenEntry a single entry of a cashu token - * @param preference optional preference for splitting proofs into specific amounts. - * @param counter? optionally set counter to derive secret deterministically. CashuWallet class must be initialized with seed phrase to take effect - * @param pubkey? optionally locks ecash to pubkey. Will not be deterministic, even if counter is set! - * @param privkey? will create a signature on the @param tokenEntry secrets if set - * @returns New token entry with newly created proofs, proofs that had errors + * @param options.keyksetId? override the keysetId derived from the current mintKeys with a custom one. This should be a keyset that was fetched from the `/keysets` endpoint + * @param options.outputAmounts? optionally specify the output's amounts to keep. + * @param options.counter? optionally set counter to derive secret deterministically. CashuWallet class must be initialized with seed phrase to take effect + * @param options.pubkey? optionally locks ecash to pubkey. Will not be deterministic, even if counter is set! + * @param options.privkey? will create a signature on the @param tokenEntry secrets if set + * @returns {Promise>} New token entry with newly created proofs, proofs that had errors */ async receiveTokenEntry( tokenEntry: TokenEntry, options?: { keysetId?: string; - preference?: Array; + outputAmounts?: OutputAmounts; counter?: number; pubkey?: string; privkey?: string; } ): Promise> { const proofs: Array = []; - const amount = tokenEntry.proofs.reduce((total: number, curr: Proof) => total + curr.amount, 0); - let preference = options?.preference; const keys = await this.getKeys(options?.keysetId); - if (!preference) { - preference = getDefaultAmountPreference(amount, keys); - } - const pref: Preferences = { sendPreference: preference }; + const amount = + tokenEntry.proofs.reduce((total: number, curr: Proof) => total + curr.amount, 0) - + this.getFeesForProofs(tokenEntry.proofs); const { payload, blindedMessages } = this.createSwapPayload( amount, tokenEntry.proofs, keys, - pref, + options?.outputAmounts, options?.counter, options?.pubkey, options?.privkey ); - const { signatures } = await CashuMint.split(tokenEntry.mint, payload); + const { signatures } = await this.mint.swap(payload); const newProofs = this.constructProofs( signatures, blindedMessages.rs, @@ -194,96 +314,296 @@ class CashuWallet { return proofs; } + /** + * Send proofs of a given amount, by providing at least the required amount of proofs + * @param amount amount to send + * @param proofs array of proofs (accumulated amount of proofs must be >= than amount) + * @param options.outputAmounts? optionally specify the output's amounts to keep and send. + * @param options.counter? optionally set counter to derive secret deterministically. CashuWallet class must be initialized with seed phrase to take effect + * @param options.proofsWeHave? optionally provide all currently stored proofs of this mint. Cashu-ts will use them to derive the optimal output amounts + * @param options.pubkey? optionally locks ecash to pubkey. Will not be deterministic, even if counter is set! + * @param options.privkey? will create a signature on the output secrets if set + * @param options.keysetId? override the keysetId derived from the current mintKeys with a custom one. This should be a keyset that was fetched from the `/keysets` endpoint + * @param options.offline? optionally send proofs offline. + * @param options.includeFees? optionally include fees in the response. + * @returns {SendResponse} + */ + async send( + amount: number, + proofs: Array, + options?: { + outputAmounts?: OutputAmounts; + proofsWeHave?: Array; + counter?: number; + pubkey?: string; + privkey?: string; + keysetId?: string; + offline?: boolean; + includeFees?: boolean; + } + ): Promise { + if (sumProofs(proofs) < amount) { + throw new Error('Not enough funds available to send'); + } + const { keep: keepProofsOffline, send: sendProofOffline } = this.selectProofsToSend( + proofs, + amount, + options?.includeFees + ); + const expectedFee = options?.includeFees ? this.getFeesForProofs(sendProofOffline) : 0; + if ( + !options?.offline && + (sumProofs(sendProofOffline) != amount + expectedFee || // if the exact amount cannot be selected + options?.outputAmounts || + options?.pubkey || + options?.privkey || + options?.keysetId) // these options require a swap + ) { + // we need to swap + // input selection, needs fees because of the swap + const { keep: keepProofsSelect, send: sendProofs } = this.selectProofsToSend( + proofs, + amount, + true + ); + options?.proofsWeHave?.push(...keepProofsSelect); + + const { keep, send } = await this.swap(amount, sendProofs, options); + const keepProofs = keepProofsSelect.concat(keep); + return { keep: keepProofs, send }; + } + + if (sumProofs(sendProofOffline) < amount + expectedFee) { + throw new Error('Not enough funds available to send'); + } + + return { keep: keepProofsOffline, send: sendProofOffline }; + } + + selectProofsToSend( + proofs: Array, + amountToSend: number, + includeFees?: boolean + ): SendResponse { + const sortedProofs = proofs.sort((a: Proof, b: Proof) => a.amount - b.amount); + const smallerProofs = sortedProofs + .filter((p: Proof) => p.amount <= amountToSend) + .sort((a: Proof, b: Proof) => b.amount - a.amount); + const biggerProofs = sortedProofs + .filter((p: Proof) => p.amount > amountToSend) + .sort((a: Proof, b: Proof) => a.amount - b.amount); + const nextBigger = biggerProofs[0]; + if (!smallerProofs.length && nextBigger) { + return { + keep: proofs.filter((p: Proof) => p.secret !== nextBigger.secret), + send: [nextBigger] + }; + } + + if (!smallerProofs.length && !nextBigger) { + return { keep: proofs, send: [] }; + } + + let remainder = amountToSend; + let selectedProofs = [smallerProofs[0]]; + const returnedProofs = []; + const feePPK = includeFees ? this.getFeesForProofs(selectedProofs) : 0; + remainder -= selectedProofs[0].amount - feePPK / 1000; + if (remainder > 0) { + const { keep, send } = this.selectProofsToSend( + smallerProofs.slice(1), + remainder, + includeFees + ); + selectedProofs.push(...send); + returnedProofs.push(...keep); + } + + const selectedFeePPK = includeFees ? this.getFeesForProofs(selectedProofs) : 0; + if (sumProofs(selectedProofs) < amountToSend + selectedFeePPK && nextBigger) { + selectedProofs = [nextBigger]; + } + return { + keep: proofs.filter((p: Proof) => !selectedProofs.includes(p)), + send: selectedProofs + }; + } + + /** + * calculates the fees based on inputs (proofs) + * @param proofs input proofs to calculate fees for + * @returns fee amount + */ + getFeesForProofs(proofs: Array): number { + if (!this._keysets.length) { + throw new Error('Could not calculate fees. No keysets found'); + } + const keysetIds = new Set(proofs.map((p: Proof) => p.id)); + keysetIds.forEach((id: string) => { + if (!this._keysets.find((k: MintKeyset) => k.id === id)) { + throw new Error(`Could not calculate fees. No keyset found with id: ${id}`); + } + }); + + const fees = Math.floor( + Math.max( + (proofs.reduce( + (total: number, curr: Proof) => + total + (this._keysets.find((k: MintKeyset) => k.id === curr.id)?.input_fee_ppk || 0), + 0 + ) + + 999) / + 1000, + 0 + ) + ); + return fees; + } + + /** + * calculates the fees based on inputs for a given keyset + * @param nInputs number of inputs + * @param keysetId keysetId used to lookup `input_fee_ppk` + * @returns fee amount + */ + getFeesForKeyset(nInputs: number, keysetId: string): number { + const fees = Math.floor( + Math.max( + (nInputs * (this._keysets.find((k: MintKeyset) => k.id === keysetId)?.input_fee_ppk || 0) + + 999) / + 1000, + 0 + ) + ); + return fees; + } + /** * Splits and creates sendable tokens * if no amount is specified, the amount is implied by the cumulative amount of all proofs * if both amount and preference are set, but the preference cannot fulfill the amount, then we use the default split * @param amount amount to send while performing the optimal split (least proofs possible). can be set to undefined if preference is set * @param proofs proofs matching that amount - * @param preference optional preference for splitting proofs into specific amounts. overrides amount param - * @param counter? optionally set counter to derive secret deterministically. CashuWallet class must be initialized with seed phrase to take effect - * @param pubkey? optionally locks ecash to pubkey. Will not be deterministic, even if counter is set! - * @param privkey? will create a signature on the @param proofs secrets if set + * @param options.outputAmounts? optionally specify the output's amounts to keep and to send. + * @param options.counter? optionally set counter to derive secret deterministically. CashuWallet class must be initialized with seed phrase to take effect + * @param options.keysetId? override the keysetId derived from the current mintKeys with a custom one. This should be a keyset that was fetched from the `/keysets` endpoint + * @param options.includeFees? include estimated fees for the receiver to receive the proofs + * @param options.proofsWeHave? optionally provide all currently stored proofs of this mint. Cashu-ts will use them to derive the optimal output amounts + * @param options.pubkey? optionally locks ecash to pubkey. Will not be deterministic, even if counter is set! + * @param options.privkey? will create a signature on the @param proofs secrets if set * @returns promise of the change- and send-proofs */ - async send( + async swap( amount: number, proofs: Array, options?: { - preference?: Preferences | Array; + outputAmounts?: OutputAmounts; + proofsWeHave?: Array; counter?: number; pubkey?: string; privkey?: string; keysetId?: string; + includeFees?: boolean; } ): Promise { - if (options?.preference) { - if (isAmountPreferenceArray(options.preference)) { - options.preference = deprecatedAmountPreferences(options.preference); + if (!options) options = {}; + const keyset = await this.getKeys(options.keysetId); + const proofsToSend = proofs; + let amountToSend = amount; + const amountAvailable = sumProofs(proofs); + let amountToKeep = amountAvailable - amountToSend - this.getFeesForProofs(proofsToSend); + // send output selection + let sendAmounts = options?.outputAmounts?.sendAmounts || splitAmount(amountToSend, keyset.keys); + + // include the fees to spend the the outputs of the swap + if (options?.includeFees) { + let outputFee = this.getFeesForKeyset(sendAmounts.length, keyset.id); + let sendAmountsFee = splitAmount(outputFee, keyset.keys); + while ( + this.getFeesForKeyset(sendAmounts.concat(sendAmountsFee).length, keyset.id) > outputFee + ) { + outputFee++; + sendAmountsFee = splitAmount(outputFee, keyset.keys); } - amount = options?.preference?.sendPreference.reduce( - (acc: number, curr: AmountPreference) => acc + curr.amount * curr.count, - 0 - ); + sendAmounts = sendAmounts.concat(sendAmountsFee); + amountToSend += outputFee; + amountToKeep -= outputFee; } - const keyset = await this.getKeys(options?.keysetId); - let amountAvailable = 0; - const proofsToSend: Array = []; - const proofsToKeep: Array = []; - proofs.forEach((proof: Proof) => { - if (amountAvailable >= amount) { - proofsToKeep.push(proof); - return; - } - amountAvailable = amountAvailable + proof.amount; - proofsToSend.push(proof); - }); - if (amount > amountAvailable) { - throw new Error('Not enough funds available'); - } - if (amount < amountAvailable || options?.preference || options?.pubkey) { - const { amountKeep, amountSend } = this.splitReceive(amount, amountAvailable); - const { payload, blindedMessages } = this.createSwapPayload( - amountSend, - proofsToSend, - keyset, - options?.preference, - options?.counter, - options?.pubkey, - options?.privkey + // keep output selection + let keepAmounts; + if (options && !options.outputAmounts?.keepAmounts && options.proofsWeHave) { + keepAmounts = getKeepAmounts( + options.proofsWeHave, + amountToKeep, + keyset.keys, + this._denominationTarget ); - const { signatures } = await this.mint.split(payload); - const proofs = this.constructProofs( - signatures, - blindedMessages.rs, - blindedMessages.secrets, - keyset + } else if (options.outputAmounts) { + if ( + options.outputAmounts.keepAmounts?.reduce((a: number, b: number) => a + b, 0) != + amountToKeep + ) { + throw new Error('Keep amounts do not match amount to keep'); + } + keepAmounts = options.outputAmounts.keepAmounts; + } + + if (amountToSend + this.getFeesForProofs(proofsToSend) > amountAvailable) { + console.error( + `Not enough funds available (${amountAvailable}) for swap amountToSend: ${amountToSend} + fee: ${this.getFeesForProofs( + proofsToSend + )} | length: ${proofsToSend.length}` ); - // sum up proofs until amount2 is reached - const splitProofsToKeep: Array = []; - const splitProofsToSend: Array = []; - let amountKeepCounter = 0; - proofs.forEach((proof: Proof) => { - if (amountKeepCounter < amountKeep) { - amountKeepCounter += proof.amount; - splitProofsToKeep.push(proof); - return; - } - splitProofsToSend.push(proof); - }); - return { - returnChange: [...splitProofsToKeep, ...proofsToKeep], - send: splitProofsToSend - }; + throw new Error(`Not enough funds available for swap`); } - return { returnChange: proofsToKeep, send: proofsToSend }; + + if (amountToSend + this.getFeesForProofs(proofsToSend) + amountToKeep != amountAvailable) { + throw new Error('Amounts do not match for swap'); + } + + options.outputAmounts = { + keepAmounts: keepAmounts, + sendAmounts: sendAmounts + }; + const { payload, blindedMessages } = this.createSwapPayload( + amountToSend, + proofsToSend, + keyset, + options?.outputAmounts, + options?.counter, + options?.pubkey, + options?.privkey + ); + const { signatures } = await this.mint.swap(payload); + const swapProofs = this.constructProofs( + signatures, + blindedMessages.rs, + blindedMessages.secrets, + keyset + ); + const splitProofsToKeep: Array = []; + const splitProofsToSend: Array = []; + let amountToKeepCounter = 0; + swapProofs.forEach((proof: Proof) => { + if (amountToKeepCounter < amountToKeep) { + amountToKeepCounter += proof.amount; + splitProofsToKeep.push(proof); + return; + } + splitProofsToSend.push(proof); + }); + return { + keep: splitProofsToKeep, + send: splitProofsToSend + }; } /** * Regenerates * @param start set starting point for count (first cycle for each keyset should usually be 0) * @param count set number of blinded messages that should be generated + * @param options.keysetId set a custom keysetId to restore from. keysetIds can be loaded with `CashuMint.getKeySets()` * @returns proofs */ async restore( @@ -316,30 +636,6 @@ class CashuWallet { }; } - /** - * Initialize the wallet with the mints public keys - */ - private async getKeys(keysetId?: string, unit?: string): Promise { - if (!this._keys || (keysetId !== undefined && this._keys.id !== keysetId)) { - const allKeys = await this.mint.getKeys(keysetId); - let keys; - if (keysetId) { - keys = allKeys.keysets.find((k: MintKeys) => k.id === keysetId); - } else { - keys = allKeys.keysets.find((k: MintKeys) => (unit ? k.unit === unit : k.unit === 'sat')); - } - if (!keys) { - throw new Error( - `could not initialize keys. No keyset with unit '${unit ? unit : 'sat'}' found` - ); - } - if (!this._keys) { - this._keys = keys; - } - } - return this._keys; - } - /** * Requests a mint quote form the mint. Response returns a Lightning payment request for the requested given amount and unit. * @param amount Amount requesting for mint. @@ -365,26 +661,44 @@ class CashuWallet { } /** - * Mint tokens for a given mint quote + * Mint proofs for a given mint quote * @param amount amount to request * @param quote ID of mint quote + * @param options.keysetId? optionally set keysetId for blank outputs for returned change. + * @param options.preference? Deprecated. Use `outputAmounts` instead. Optional preference for splitting proofs into specific amounts. + * @param options.outputAmounts? optionally specify the output's amounts to keep and to send. + * @param options.counter? optionally set counter to derive secret deterministically. CashuWallet class must be initialized with seed phrase to take effect + * @param options.pubkey? optionally locks ecash to pubkey. Will not be deterministic, even if counter is set! * @returns proofs */ - async mintTokens( + async mintProofs( amount: number, quote: string, options?: { keysetId?: string; - preference?: Array; + outputAmounts?: OutputAmounts; + proofsWeHave?: Array; counter?: number; pubkey?: string; } ): Promise<{ proofs: Array }> { const keyset = await this.getKeys(options?.keysetId); + if (!options?.outputAmounts && options?.proofsWeHave) { + options.outputAmounts = { + keepAmounts: getKeepAmounts( + options.proofsWeHave, + amount, + keyset.keys, + this._denominationTarget + ), + sendAmounts: [] + }; + } + const { blindedMessages, secrets, rs } = this.createRandomBlindedMessages( amount, keyset, - options?.preference, + options?.outputAmounts?.keepAmounts, options?.counter, options?.pubkey ); @@ -423,8 +737,8 @@ class CashuWallet { } /** - * Melt tokens for a melt quote. proofsToSend must be at least amount+fee_reserve form the melt quote. - * Returns payment proof and change proofs + * Melt proofs for a melt quote. proofsToSend must be at least amount+fee_reserve form the melt quote. This function does not perform coin selection!. + * Returns melt quote and change proofs * @param meltQuote ID of the melt quote * @param proofsToSend proofs to melt * @param options.keysetId? optionally set keysetId for blank outputs for returned change. @@ -432,7 +746,7 @@ class CashuWallet { * @param options.privkey? optionally set a private key to unlock P2PK locked secrets * @returns */ - async meltTokens( + async meltProofs( meltQuote: MeltQuoteResponse, proofsToSend: Array, options?: { @@ -440,11 +754,10 @@ class CashuWallet { counter?: number; privkey?: string; } - ): Promise { + ): Promise { const keys = await this.getKeys(options?.keysetId); - const { blindedMessages, secrets, rs } = this.createBlankOutputs( - meltQuote.fee_reserve, + sumProofs(proofsToSend) - meltQuote.amount, keys.id, options?.counter ); @@ -467,79 +780,21 @@ class CashuWallet { outputs: [...blindedMessages] }; const meltResponse = await this.mint.melt(meltPayload); - + let change: Array = []; + if (meltResponse.change) { + change = this.constructProofs(meltResponse.change, rs, secrets, keys); + } return { - isPaid: meltResponse.state === MeltQuoteState.PAID, - preimage: meltResponse.payment_preimage, - change: meltResponse?.change - ? this.constructProofs(meltResponse.change, rs, secrets, keys) - : [] + quote: meltResponse, + change: change }; } - /** - * Helper function that pays a Lightning invoice directly without having to create a melt quote before - * The combined amount of Proofs must match the payment amount including fees. - * @param invoice - * @param proofsToSend the exact amount to send including fees - * @param meltQuote melt quote for the invoice - * @param options.keysetId? optionally set keysetId for blank outputs for returned change. - * @param options.counter? optionally set counter to derive secret deterministically. CashuWallet class must be initialized with seed phrase to take effect - * @param options.privkey? optionally set a private key to unlock P2PK locked secrets - * @returns - */ - async payLnInvoice( - invoice: string, - proofsToSend: Array, - meltQuote?: MeltQuoteResponse, - options?: { - keysetId?: string; - counter?: number; - privkey?: string; - } - ): Promise { - if (!meltQuote) { - meltQuote = await this.mint.createMeltQuote({ unit: this._unit, request: invoice }); - } - return await this.meltTokens(meltQuote, proofsToSend, { - keysetId: options?.keysetId, - counter: options?.counter, - privkey: options?.privkey - }); - } - - /** - * Helper function to ingest a Cashu token and pay a Lightning invoice with it. - * @param invoice Lightning invoice - * @param token cashu token - * @param meltQuote melt quote for the invoice - * @param options.keysetId? optionally set keysetId for blank outputs for returned change. - * @param options.counter? optionally set counter to derive secret deterministically. CashuWallet class must be initialized with seed phrase to take effect - */ - async payLnInvoiceWithToken( - invoice: string, - token: string, - meltQuote: MeltQuoteResponse, - options?: { - keysetId?: string; - counter?: number; - } - ): Promise { - const decodedToken = getDecodedToken(token); - const proofs = decodedToken.token - .filter((x: TokenEntry) => x.mint === this.mint.mintUrl) - .flatMap((t: TokenEntry) => t.proofs); - return this.payLnInvoice(invoice, proofs, meltQuote, { - keysetId: options?.keysetId, - counter: options?.counter - }); - } - /** * Creates a split payload * @param amount amount to send * @param proofsToSend proofs to split* - * @param preference optional preference for splitting proofs into specific amounts. overrides amount param + * @param outputAmounts? optionally specify the output's amounts to keep and to send. * @param counter? optionally set counter to derive secret deterministically. CashuWallet class must be initialized with seed phrase to take effect * @param pubkey? optionally locks ecash to pubkey. Will not be deterministic, even if counter is set! * @param privkey? will create a signature on the @param proofsToSend secrets if set @@ -549,7 +804,7 @@ class CashuWallet { amount: number, proofsToSend: Array, keyset: MintKeys, - preference?: Preferences | Array, + outputAmounts?: OutputAmounts, counter?: number, pubkey?: string, privkey?: string @@ -557,14 +812,17 @@ class CashuWallet { payload: SwapPayload; blindedMessages: BlindedTransaction; } { - if (isAmountPreferenceArray(preference)) { - preference = deprecatedAmountPreferences(preference); - } const totalAmount = proofsToSend.reduce((total: number, curr: Proof) => total + curr.amount, 0); + if (outputAmounts && outputAmounts.sendAmounts && !outputAmounts.keepAmounts) { + outputAmounts.keepAmounts = splitAmount( + totalAmount - amount - this.getFeesForProofs(proofsToSend), + keyset.keys + ); + } const keepBlindedMessages = this.createRandomBlindedMessages( - totalAmount - amount, + totalAmount - amount - this.getFeesForProofs(proofsToSend), keyset, - preference?.keepPreference, + outputAmounts?.keepAmounts, counter ); if (this._seed && counter) { @@ -573,7 +831,7 @@ class CashuWallet { const sendBlindedMessages = this.createRandomBlindedMessages( amount, keyset, - preference?.sendPreference, + outputAmounts?.sendAmounts, counter, pubkey ); @@ -610,7 +868,7 @@ class CashuWallet { } /** * returns proofs that are already spent (use for keeping wallet state clean) - * @param proofs (only the 'Y' field is required) + * @param proofs (only the `secret` field is required) * @returns */ async checkProofsSpent(proofs: Array): Promise> { @@ -627,19 +885,11 @@ class CashuWallet { return state && state.state === CheckStateEnum.SPENT; }); } - private splitReceive( - amount: number, - amountAvailable: number - ): { amountKeep: number; amountSend: number } { - const amountKeep: number = amountAvailable - amount; - const amountSend: number = amount; - return { amountKeep, amountSend }; - } /** * Creates blinded messages for a given amount * @param amount amount to create blinded messages for - * @param amountPreference optional preference for splitting proofs into specific amounts. overrides amount param + * @param split optional preference for splitting proofs into specific amounts. overrides amount param * @param keyksetId? override the keysetId derived from the current mintKeys with a custom one. This should be a keyset that was fetched from the `/keysets` endpoint * @param counter? optionally set counter to derive secret deterministically. CashuWallet class must be initialized with seed phrase to take effect * @param pubkey? optionally locks ecash to pubkey. Will not be deterministic, even if counter is set! @@ -648,11 +898,11 @@ class CashuWallet { private createRandomBlindedMessages( amount: number, keyset: MintKeys, - amountPreference?: Array, + split?: Array, counter?: number, pubkey?: string ): BlindedMessageData & { amounts: Array } { - const amounts = splitAmount(amount, keyset.keys, amountPreference); + const amounts = splitAmount(amount, keyset.keys, split); return this.createBlindedMessages(amounts, keyset.id, counter, pubkey); } @@ -706,17 +956,17 @@ class CashuWallet { /** * Creates NUT-08 blank outputs (fee returns) for a given fee reserve * See: https://github.com/cashubtc/nuts/blob/main/08.md - * @param feeReserve amount to cover with blank outputs + * @param amount amount to cover with blank outputs * @param keysetId mint keysetId * @param counter? optionally set counter to derive secret deterministically. CashuWallet class must be initialized with seed phrase to take effect * @returns blinded messages, secrets, and rs */ private createBlankOutputs( - feeReserve: number, + amount: number, keysetId: string, counter?: number ): BlindedMessageData { - let count = Math.ceil(Math.log2(feeReserve)) || 1; + let count = Math.ceil(Math.log2(amount)) || 1; //Prevent count from being -Infinity if (count < 0) { count = 0; diff --git a/src/legacy/cashu-ts.ts b/src/legacy/cashu-ts.ts deleted file mode 100644 index 05609145..00000000 --- a/src/legacy/cashu-ts.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { AmountPreference, Preferences } from '../model/types/index'; - -export const deprecatedAmountPreferences = function (pref: Array): Preferences { - console.warn('[DEPRECATION] Use `Preferences` instead of `Array`'); - return { sendPreference: pref }; -}; - -export const isAmountPreference = function (obj: any): obj is AmountPreference { - return ( - typeof obj === 'object' && - obj !== null && - 'amount' in obj && - 'count' in obj && - typeof obj.amount === 'number' && - typeof obj.count === 'number' - ); -}; - -export const isAmountPreferenceArray = function ( - preference?: any -): preference is Array { - return Array.isArray(preference) && preference.every((item) => isAmountPreference(item)); -}; diff --git a/src/legacy/nut-06.ts b/src/legacy/nut-06.ts index ded61049..82438d8b 100644 --- a/src/legacy/nut-06.ts +++ b/src/legacy/nut-06.ts @@ -11,11 +11,11 @@ export function handleMintInfoContactFieldDeprecated(data: GetInfoResponse) { typeof contact[0] === 'string' && typeof contact[1] === 'string' ) { + console.warn( + `Mint returned deprecated 'contact' field: Update NUT-06: https://github.com/cashubtc/nuts/pull/117` + ); return { method: contact[0], info: contact[1] } as MintContactInfo; } - console.warn( - "Mint returned deprecated 'contact' field. Update NUT-06: https://github.com/cashubtc/nuts/pull/117" - ); return contact; }); } diff --git a/src/model/types/index.ts b/src/model/types/index.ts index 5fc2e3b7..8203aee6 100644 --- a/src/model/types/index.ts +++ b/src/model/types/index.ts @@ -1,13 +1,13 @@ -import { AmountPreference } from './wallet/index'; - export * from './mint/index'; export * from './wallet/index'; -export type Preferences = { - sendPreference: Array; - keepPreference?: Array; +export type OutputAmounts = { + sendAmounts: Array; + keepAmounts?: Array; }; +// deprecated + export type InvoiceData = { paymentRequest: string; amountInSats?: number; diff --git a/src/model/types/mint/keys.ts b/src/model/types/mint/keys.ts index c9ab3b90..794c8acd 100644 --- a/src/model/types/mint/keys.ts +++ b/src/model/types/mint/keys.ts @@ -57,4 +57,8 @@ export type MintKeyset = { * Whether the keyset is active or not. */ active: boolean; + /** + * Input fee for keyset (in ppk) + */ + input_fee_ppk?: number; }; diff --git a/src/model/types/wallet/index.ts b/src/model/types/wallet/index.ts index 3efc7d39..853df5d9 100644 --- a/src/model/types/wallet/index.ts +++ b/src/model/types/wallet/index.ts @@ -3,11 +3,6 @@ export * from './responses'; export * from './tokens'; export * from './paymentRequests'; -export type AmountPreference = { - amount: number; - count: number; -}; - /** * represents a single Cashu proof. */ diff --git a/src/model/types/wallet/responses.ts b/src/model/types/wallet/responses.ts index 957b90f0..c3f93ea6 100644 --- a/src/model/types/wallet/responses.ts +++ b/src/model/types/wallet/responses.ts @@ -1,17 +1,14 @@ +import { MeltQuoteResponse } from '../mint'; import { Proof, Token } from './index'; /** * Response after paying a Lightning invoice */ -export type MeltTokensResponse = { +export type MeltProofsResponse = { /** * if false, the proofs have not been invalidated and the payment can be tried later again with the same proofs */ - isPaid: boolean; - /** - * preimage of the paid invoice. can be null, depending on which LN-backend the mint uses - */ - preimage: string | null; + quote: MeltQuoteResponse; /** * Return/Change from overpaid fees. This happens due to Lighting fee estimation being inaccurate */ @@ -39,7 +36,7 @@ export type SendResponse = { /** * Proofs that exceeded the needed amount */ - returnChange: Array; + keep: Array; /** * Proofs to be sent, matching the chosen amount */ diff --git a/src/utils.ts b/src/utils.ts index a361eb69..d7320b69 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -5,7 +5,6 @@ import { encodeUint8toBase64Url } from './base64.js'; import { - AmountPreference, Keys, Proof, RawPaymentRequest, @@ -22,94 +21,162 @@ import { sha256 } from '@noble/hashes/sha256'; import { decodeCBOR, encodeCBOR } from './cbor.js'; import { PaymentRequest } from './model/PaymentRequest.js'; -function splitAmount( +/** + * Splits the amount into denominations of the provided @param keyset + * @param value amount to split + * @param keyset keys to look up split amounts + * @param split? optional custom split amounts + * @param order? optional order for split amounts (default: "asc") + * @returns Array of split amounts + * @throws Error if @param split amount is greater than @param value amount + */ +export function splitAmount( value: number, keyset: Keys, - amountPreference?: Array, - isDesc?: boolean + split?: Array, + order?: 'desc' | 'asc' ): Array { - const chunks: Array = []; - if (amountPreference) { - chunks.push(...getPreference(value, keyset, amountPreference)); + if (split) { + if (split.reduce((a: number, b: number) => a + b, 0) > value) { + throw new Error( + `Split is greater than total amount: ${split.reduce( + (a: number, b: number) => a + b, + 0 + )} > ${value}` + ); + } + split.forEach((amt: number) => { + if (!hasCorrespondingKey(amt, keyset)) { + throw new Error('Provided amount preferences do not match the amounts of the mint keyset.'); + } + }); value = value - - chunks.reduce((curr: number, acc: number) => { + split.reduce((curr: number, acc: number) => { return curr + acc; }, 0); + } else { + split = []; } - const sortedKeyAmounts: Array = Object.keys(keyset) - .map((k) => parseInt(k)) - .sort((a, b) => b - a); - sortedKeyAmounts.forEach((amt) => { + const sortedKeyAmounts = getKeysetAmounts(keyset); + sortedKeyAmounts.forEach((amt: number) => { const q = Math.floor(value / amt); - for (let i = 0; i < q; ++i) chunks.push(amt); + for (let i = 0; i < q; ++i) split?.push(amt); value %= amt; }); - return chunks.sort((a, b) => (isDesc ? b - a : a - b)); -} - -/* -function isPowerOfTwo(number: number) { - return number && !(number & (number - 1)); -} -*/ - -function hasCorrespondingKey(amount: number, keyset: Keys): boolean { - return amount in keyset; + return split.sort((a, b) => (order === 'desc' ? b - a : a - b)); } -function getPreference( - amount: number, - keyset: Keys, - preferredAmounts: Array +/** + * Creates a list of amounts to keep based on the proofs we have and the proofs we want to reach. + * @param proofsWeHave complete set of proofs stored (from current mint) + * @param amountToKeep amount to keep + * @param keys keys of current keyset + * @param targetCount the target number of proofs to reach + * @returns an array of amounts to keep + */ +export function getKeepAmounts( + proofsWeHave: Array, + amountToKeep: number, + keys: Keys, + targetCount: number ): Array { - const chunks: Array = []; - let accumulator = 0; - preferredAmounts.forEach((pa: AmountPreference) => { - if (!hasCorrespondingKey(pa.amount, keyset)) { - throw new Error('Provided amount preferences do not match the amounts of the mint keyset.'); - } - for (let i = 1; i <= pa.count; i++) { - accumulator += pa.amount; - if (accumulator > amount) { - return; + // determines amounts we need to reach the targetCount for each amount based on the amounts of the proofs we have + // it tries to select amounts so that the proofs we have and the proofs we want reach the targetCount + const amountsWeWant: Array = []; + const amountsWeHave = proofsWeHave.map((p: Proof) => p.amount); + const sortedKeyAmounts = getKeysetAmounts(keys, 'asc'); + sortedKeyAmounts.forEach((amt) => { + const countWeHave = amountsWeHave.filter((a) => a === amt).length; + const countWeWant = Math.max(targetCount - countWeHave, 0); + for (let i = 0; i < countWeWant; ++i) { + if (amountsWeWant.reduce((a, b) => a + b, 0) + amt > amountToKeep) { + break; } - chunks.push(pa.amount); + amountsWeWant.push(amt); } }); - return chunks; + // use splitAmount to fill the rest between the sum of amountsWeHave and amountToKeep + const amountDiff = amountToKeep - amountsWeWant.reduce((a, b) => a + b, 0); + if (amountDiff) { + const remainingAmounts = splitAmount(amountDiff, keys); + remainingAmounts.forEach((amt: number) => { + amountsWeWant.push(amt); + }); + } + const sortedAmountsWeWant = amountsWeWant.sort((a, b) => a - b); + return sortedAmountsWeWant; +} +/** + * returns the amounts in the keyset sorted by the order specified + * @param keyset to search in + * @param order order to sort the amounts in + * @returns the amounts in the keyset sorted by the order specified + */ +export function getKeysetAmounts(keyset: Keys, order: 'asc' | 'desc' = 'desc'): Array { + if (order == 'desc') { + return Object.keys(keyset) + .map((k: string) => parseInt(k)) + .sort((a: number, b: number) => b - a); + } + return Object.keys(keyset) + .map((k: string) => parseInt(k)) + .sort((a: number, b: number) => a - b); } -function getDefaultAmountPreference(amount: number, keyset: Keys): Array { - const amounts = splitAmount(amount, keyset); - return amounts.map((a: number) => { - return { amount: a, count: 1 }; - }); +/** + * Checks if the provided amount is in the keyset. + * @param amount amount to check + * @param keyset to search in + * @returns true if the amount is in the keyset, false otherwise + */ +export function hasCorrespondingKey(amount: number, keyset: Keys): boolean { + return amount in keyset; } -function bytesToNumber(bytes: Uint8Array): bigint { +/** + * Converts a bytes array to a number. + * @param bytes to convert to number + * @returns number + */ +export function bytesToNumber(bytes: Uint8Array): bigint { return hexToNumber(bytesToHex(bytes)); } -function hexToNumber(hex: string): bigint { +/** + * Converts a hex string to a number. + * @param hex to convert to number + * @returns number + */ +export function hexToNumber(hex: string): bigint { return BigInt(`0x${hex}`); } -//used for json serialization -function bigIntStringify(_key: unknown, value: T) { +/** + * Helper function to stringify a bigint + * @param _key + * @param value to stringify + * @returns stringified bigint + */ +export function bigIntStringify(_key: unknown, value: T): string | T { return typeof value === 'bigint' ? value.toString() : value; } /** * Helper function to encode a v3 cashu token - * @param token - * @returns + * @param token to encode + * @returns encoded token */ -function getEncodedToken(token: Token): string { +export function getEncodedToken(token: Token): string { return TOKEN_PREFIX + TOKEN_VERSION + encodeJsonToBase64(token); } -function getEncodedTokenV4(token: Token): string { +/** + * Helper function to encode a v4 cashu token + * @param token to encode + * @returns encoded token + */ +export function getEncodedTokenV4(token: Token): string { const idMap: { [id: string]: Array } = {}; let mint: string | undefined = undefined; for (let i = 0; i < token.token.length; i++) { @@ -158,7 +225,7 @@ function getEncodedTokenV4(token: Token): string { * @param token an encoded cashu token (cashuAey...) * @returns cashu token object */ -function getDecodedToken(token: string) { +export function getDecodedToken(token: string) { // remove prefixes const uriPrefixes = ['web+cashu://', 'cashu://', 'cashu:', 'cashu']; uriPrefixes.forEach((prefix: string) => { @@ -171,10 +238,11 @@ function getDecodedToken(token: string) { } /** - * @param token - * @returns + * Helper function to decode different versions of cashu tokens into an object + * @param token an encoded cashu token (cashuAey...) + * @returns cashu Token object */ -function handleTokens(token: string): Token { +export function handleTokens(token: string): Token { const version = token.slice(0, 1); const encodedToken = token.slice(1); if (version === 'A') { @@ -217,7 +285,7 @@ export function deriveKeysetId(keys: Keys) { return '00' + hashHex; } -function mergeUInt8Arrays(a1: Uint8Array, a2: Uint8Array): Uint8Array { +export function mergeUInt8Arrays(a1: Uint8Array, a2: Uint8Array): Uint8Array { // sum of individual array lengths const mergedArray = new Uint8Array(a1.length + a2.length); mergedArray.set(a1); @@ -251,18 +319,10 @@ export function sanitizeUrl(url: string): string { return url.replace(/\/$/, ''); } -function decodePaymentRequest(paymentRequest: string) { - return PaymentRequest.fromEncodedRequest(paymentRequest); +export function sumProofs(proofs: Array) { + return proofs.reduce((acc: number, proof: Proof) => acc + proof.amount, 0); } -export { - bigIntStringify, - bytesToNumber, - getDecodedToken, - getEncodedToken, - getEncodedTokenV4, - hexToNumber, - splitAmount, - getDefaultAmountPreference, - decodePaymentRequest -}; +export function decodePaymentRequest(paymentRequest: string) { + return PaymentRequest.fromEncodedRequest(paymentRequest); +} diff --git a/test/integration.test.ts b/test/integration.test.ts index 538c0680..8ca42730 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -2,9 +2,10 @@ import { CashuMint } from '../src/CashuMint.js'; import { CashuWallet } from '../src/CashuWallet.js'; import dns from 'node:dns'; -import { deriveKeysetId, getEncodedToken } from '../src/utils.js'; +import { deriveKeysetId, getEncodedToken, sumProofs } from '../src/utils.js'; import { secp256k1 } from '@noble/curves/secp256k1'; import { bytesToHex } from '@noble/curves/abstract/utils'; +import { MeltQuoteState } from '../src/model/types/index.js'; dns.setDefaultResultOrder('ipv4first'); const externalInvoice = @@ -47,7 +48,7 @@ describe('mint api', () => { const request = await wallet.createMintQuote(1337); expect(request).toBeDefined(); expect(request.request).toContain('lnbc1337'); - const tokens = await wallet.mintTokens(1337, request.quote); + const tokens = await wallet.mintProofs(1337, request.quote); expect(tokens).toBeDefined(); // expect that the sum of all tokens.proofs.amount is equal to the requested amount expect(tokens.proofs.reduce((a, b) => a + b.amount, 0)).toBe(1337); @@ -80,7 +81,7 @@ describe('mint api', () => { const mint = new CashuMint(mintUrl); const wallet = new CashuWallet(mint, { unit }); const request = await wallet.createMintQuote(100); - const tokens = await wallet.mintTokens(100, request.quote); + const tokens = await wallet.mintProofs(100, request.quote); // expect no fee because local invoice const mintQuote = await wallet.createMintQuote(10); @@ -92,8 +93,8 @@ describe('mint api', () => { const quote_ = await wallet.checkMeltQuote(quote.quote); expect(quote_).toBeDefined(); - const sendResponse = await wallet.send(10, tokens.proofs); - const response = await wallet.payLnInvoice(mintQuote.request, sendResponse.send, quote); + const sendResponse = await wallet.send(10, tokens.proofs, { includeFees: true }); + const response = await wallet.meltProofs(quote, sendResponse.send); expect(response).toBeDefined(); // expect that we have received the fee back, since it was internal expect(response.change.reduce((a, b) => a + b.amount, 0)).toBe(fee); @@ -103,16 +104,16 @@ describe('mint api', () => { expect(sentProofsSpent).toBeDefined(); // expect that all proofs are spent, i.e. sendProofsSpent == sendResponse.send expect(sentProofsSpent).toEqual(sendResponse.send); - // expect none of the sendResponse.returnChange to be spent - const returnChangeSpent = await wallet.checkProofsSpent(sendResponse.returnChange); - expect(returnChangeSpent).toBeDefined(); - expect(returnChangeSpent).toEqual([]); + // expect none of the sendResponse.keep to be spent + const keepSpent = await wallet.checkProofsSpent(sendResponse.keep); + expect(keepSpent).toBeDefined(); + expect(keepSpent).toEqual([]); }); test('pay external invoice', async () => { const mint = new CashuMint(mintUrl); const wallet = new CashuWallet(mint, { unit }); const request = await wallet.createMintQuote(3000); - const tokens = await wallet.mintTokens(3000, request.quote); + const tokens = await wallet.mintProofs(3000, request.quote); const meltQuote = await wallet.createMeltQuote(externalInvoice); const fee = meltQuote.fee_reserve; @@ -122,8 +123,8 @@ describe('mint api', () => { const quote_ = await wallet.checkMeltQuote(meltQuote.quote); expect(quote_).toBeDefined(); - const sendResponse = await wallet.send(2000 + fee, tokens.proofs); - const response = await wallet.payLnInvoice(externalInvoice, sendResponse.send, meltQuote); + const sendResponse = await wallet.send(2000 + fee, tokens.proofs, { includeFees: true }); + const response = await wallet.meltProofs(meltQuote, sendResponse.send); expect(response).toBeDefined(); // expect that we have not received the fee back, since it was external @@ -134,42 +135,45 @@ describe('mint api', () => { expect(sentProofsSpent).toBeDefined(); // expect that all proofs are spent, i.e. sendProofsSpent == sendResponse.send expect(sentProofsSpent).toEqual(sendResponse.send); - // expect none of the sendResponse.returnChange to be spent - const returnChangeSpent = await wallet.checkProofsSpent(sendResponse.returnChange); - expect(returnChangeSpent).toBeDefined(); - expect(returnChangeSpent).toEqual([]); + // expect none of the sendResponse.keep to be spent + const keepSpent = await wallet.checkProofsSpent(sendResponse.keep); + expect(keepSpent).toBeDefined(); + expect(keepSpent).toEqual([]); }); test('test send tokens exact without previous split', async () => { const mint = new CashuMint(mintUrl); const wallet = new CashuWallet(mint, { unit }); const request = await wallet.createMintQuote(64); - const tokens = await wallet.mintTokens(64, request.quote); + const tokens = await wallet.mintProofs(64, request.quote); const sendResponse = await wallet.send(64, tokens.proofs); expect(sendResponse).toBeDefined(); expect(sendResponse.send).toBeDefined(); - expect(sendResponse.returnChange).toBeDefined(); + expect(sendResponse.keep).toBeDefined(); expect(sendResponse.send.length).toBe(1); - expect(sendResponse.returnChange.length).toBe(0); + expect(sendResponse.keep.length).toBe(0); + expect(sumProofs(sendResponse.send)).toBe(64); }); test('test send tokens with change', async () => { const mint = new CashuMint(mintUrl); const wallet = new CashuWallet(mint, { unit }); const request = await wallet.createMintQuote(100); - const tokens = await wallet.mintTokens(100, request.quote); + const tokens = await wallet.mintProofs(100, request.quote); - const sendResponse = await wallet.send(10, tokens.proofs); + const sendResponse = await wallet.send(10, tokens.proofs, { includeFees: false }); expect(sendResponse).toBeDefined(); expect(sendResponse.send).toBeDefined(); - expect(sendResponse.returnChange).toBeDefined(); + expect(sendResponse.keep).toBeDefined(); expect(sendResponse.send.length).toBe(2); - expect(sendResponse.returnChange.length).toBe(4); - }); + expect(sendResponse.keep.length).toBe(5); + expect(sumProofs(sendResponse.send)).toBe(10); + expect(sumProofs(sendResponse.keep)).toBe(89); + }, 10000000); test('receive tokens with previous split', async () => { const mint = new CashuMint(mintUrl); const wallet = new CashuWallet(mint, { unit }); const request = await wallet.createMintQuote(100); - const tokens = await wallet.mintTokens(100, request.quote); + const tokens = await wallet.mintProofs(100, request.quote); const sendResponse = await wallet.send(10, tokens.proofs); const encoded = getEncodedToken({ @@ -182,7 +186,7 @@ describe('mint api', () => { const mint = new CashuMint(mintUrl); const wallet = new CashuWallet(mint, { unit }); const request = await wallet.createMintQuote(64); - const tokens = await wallet.mintTokens(64, request.quote); + const tokens = await wallet.mintProofs(64, request.quote); const encoded = getEncodedToken({ token: [{ mint: mintUrl, proofs: tokens.proofs }] }); @@ -191,7 +195,7 @@ describe('mint api', () => { }); test('send and receive p2pk', async () => { const mint = new CashuMint(mintUrl); - const wallet = new CashuWallet(mint); + const wallet = new CashuWallet(mint, { unit }); const privKeyAlice = secp256k1.utils.randomPrivateKey(); const pubKeyAlice = secp256k1.getPublicKey(privKeyAlice); @@ -199,8 +203,8 @@ describe('mint api', () => { const privKeyBob = secp256k1.utils.randomPrivateKey(); const pubKeyBob = secp256k1.getPublicKey(privKeyBob); - const request = await wallet.createMintQuote(64); - const tokens = await wallet.mintTokens(64, request.quote); + const request = await wallet.createMintQuote(128); + const tokens = await wallet.mintProofs(128, request.quote); const { send } = await wallet.send(64, tokens.proofs, { pubkey: bytesToHex(pubKeyBob) }); const encoded = getEncodedToken({ @@ -218,7 +222,7 @@ describe('mint api', () => { proofs.reduce((curr, acc) => { return curr + acc.amount; }, 0) - ).toBe(64); + ).toBe(63); }); test('mint and melt p2pk', async () => { @@ -230,17 +234,17 @@ describe('mint api', () => { const mintRequest = await wallet.createMintQuote(3000); - const proofs = await wallet.mintTokens(3000, mintRequest.quote, { + const proofs = await wallet.mintProofs(3000, mintRequest.quote, { pubkey: bytesToHex(pubKeyBob) }); const meltRequest = await wallet.createMeltQuote(externalInvoice); const fee = meltRequest.fee_reserve; expect(fee).toBeGreaterThan(0); - const response = await wallet.meltTokens(meltRequest, proofs.proofs, { + const response = await wallet.meltProofs(meltRequest, proofs.proofs, { privkey: bytesToHex(privKeyBob) }); expect(response).toBeDefined(); - expect(response.isPaid).toBe(true); + expect(response.quote.state == MeltQuoteState.PAID).toBe(true); }); }); diff --git a/test/utils.test.ts b/test/utils.test.ts index 8422f44d..8091c064 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -1,4 +1,4 @@ -import { AmountPreference, Token, Keys } from '../src/model/types/index.js'; +import { Token, Keys, Proof } from '../src/model/types/index.js'; import * as utils from '../src/utils.js'; import { PUBKEYS } from './consts.js'; @@ -29,31 +29,36 @@ describe('test split amounts ', () => { }); describe('test split custom amounts ', () => { - const fiveToOne: AmountPreference = { amount: 1, count: 5 }; + const fiveToOne = [1, 1, 1, 1, 1]; test('testing amount 5', async () => { - const chunks = utils.splitAmount(5, keys, [fiveToOne]); + const chunks = utils.splitAmount(5, keys, fiveToOne); expect(chunks).toStrictEqual([1, 1, 1, 1, 1]); }); - const tenToOneAndTwo: Array = [ - { amount: 1, count: 2 }, - { amount: 2, count: 4 } - ]; + const tenToOneAndTwo = [1, 1, 2, 2, 2, 2]; test('testing amount 10', async () => { const chunks = utils.splitAmount(10, keys, tenToOneAndTwo); expect(chunks).toStrictEqual([1, 1, 2, 2, 2, 2]); }); - const fiveTwelve: Array = [{ amount: 512, count: 2 }]; + test('testing amount 12', async () => { + const chunks = utils.splitAmount(12, keys, tenToOneAndTwo); + expect(chunks).toStrictEqual([1, 1, 2, 2, 2, 2, 2]); + }); + const fiveTwelve = [512]; test('testing amount 518', async () => { - const chunks = utils.splitAmount(518, keys, fiveTwelve, true); + const chunks = utils.splitAmount(518, keys, fiveTwelve, 'desc'); expect(chunks).toStrictEqual([512, 4, 2]); }); - const illegal: Array = [{ amount: 3, count: 2 }]; + const tooMuch = [512, 512]; + test('testing amount 512 but split too much', async () => { + expect(() => utils.splitAmount(512, keys, tooMuch)).toThrowError(); + }); + const illegal = [3, 3]; test('testing non pow2', async () => { expect(() => utils.splitAmount(6, keys, illegal)).toThrowError(); }); - const empty: Array = []; + const empty: Array = []; test('testing empty', async () => { - const chunks = utils.splitAmount(5, keys, empty, true); + const chunks = utils.splitAmount(5, keys, empty, 'desc'); expect(chunks).toStrictEqual([4, 1]); }); const undef = undefined; @@ -65,7 +70,7 @@ describe('test split custom amounts ', () => { describe('test split different key amount', () => { test('testing amount 68251', async () => { - const chunks = utils.splitAmount(68251, keys_base10, undefined, true); + const chunks = utils.splitAmount(68251, keys_base10, undefined, 'desc'); expect(chunks).toStrictEqual([ 10000, 10000, 10000, 10000, 10000, 10000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 100, 100, 10, 10, 10, 10, 10, 1 @@ -289,3 +294,33 @@ describe('test v4 encoding', () => { expect(decodedExpectedToken).toEqual(decodedEncodedToken); }); }); + +describe('test output selection', () => { + test('keep amounts', () => { + const amountsWeHave = [1, 2, 4, 4, 4, 8]; + const proofsWeHave = amountsWeHave.map((amount) => { + return { + amount: amount, + id: 'id', + C: 'C' + } as Proof; + }); + const keys = PUBKEYS as Keys; + + // info: getKeepAmounts returns the amounts we need to fill up + // the wallet to a target number of denominations plus an optimal + // split of the remaining amount (to reach the total amount) + + let amountsToKeep = utils.getKeepAmounts(proofsWeHave, 22, keys, 3); + // keeping 22 with a target count of 3, we expect two 1s, two 2s, no 4s, and two 8s, and no extra to reach 22 + expect(amountsToKeep).toEqual([1, 1, 2, 2, 8, 8]); + + // keeping 22 with a target count of 4, we expect three 1s, three 2s, one 4, and one 8 and another 1 to reach 22 + amountsToKeep = utils.getKeepAmounts(proofsWeHave, 22, keys, 4); + expect(amountsToKeep).toEqual([1, 1, 1, 1, 2, 2, 2, 4, 8]); + + // keeping 22 with a target of 2, we expect one 1, one 2, no 4s, one 8, and another 1, 2, 8 to reach 22 + amountsToKeep = utils.getKeepAmounts(proofsWeHave, 22, keys, 2); + expect(amountsToKeep).toEqual([1, 1, 2, 2, 8, 8]); + }); +}); diff --git a/test/wallet.test.ts b/test/wallet.test.ts index 85608d17..3d702a49 100644 --- a/test/wallet.test.ts +++ b/test/wallet.test.ts @@ -1,9 +1,8 @@ import nock from 'nock'; import { CashuMint } from '../src/CashuMint.js'; import { CashuWallet } from '../src/CashuWallet.js'; -import { MeltQuoteResponse, ReceiveResponse } from '../src/model/types/index.js'; +import { MeltQuoteResponse, MeltQuoteState, OutputAmounts } from '../src/model/types/index.js'; import { getDecodedToken } from '../src/utils.js'; -import { AmountPreference } from '../src/model/types/index'; import { Proof } from '@cashu/crypto/modules/common'; const dummyKeysResp = { @@ -11,7 +10,20 @@ const dummyKeysResp = { { id: '009a1f293253e41e', unit: 'sat', - keys: { 1: '02f970b6ee058705c0dddc4313721cffb7efd3d142d96ea8e01d31c2b2ff09f181' } + keys: { + 1: '02f970b6ee058705c0dddc4313721cffb7efd3d142d96ea8e01d31c2b2ff09f181', + 2: '03361cd8bd1329fea797a6add1cf1990ffcf2270ceb9fc81eeee0e8e9c1bd0cdf5' + } + } + ] +}; +const dummyKeysetResp = { + keysets: [ + { + id: '009a1f293253e41e', + unit: 'sat', + active: true, + input_fee_ppk: 0 } ] }; @@ -31,6 +43,7 @@ beforeEach(() => { nock.cleanAll(); nock(mintUrl).get('/v1/keys').reply(200, dummyKeysResp); nock(mintUrl).get('/v1/keys/009a1f293253e41e').reply(200, dummyKeysResp); + nock(mintUrl).get('/v1/keysets').reply(200, dummyKeysetResp); }); describe('test info', () => { @@ -162,7 +175,7 @@ describe('receive', () => { 'cashuAeyJ0b2tlbiI6IFt7InByb29mcyI6IFt7ImlkIjogIjAwOWExZjI5MzI1M2U0MWUiLCAiYW1vdW50IjogMSwgInNlY3JldCI6ICJlN2MxYjc2ZDFiMzFlMmJjYTJiMjI5ZDE2MGJkZjYwNDZmMzNiYzQ1NzAyMjIzMDRiNjUxMTBkOTI2ZjdhZjg5IiwgIkMiOiAiMDM4OWNkOWY0Zjk4OGUzODBhNzk4OWQ0ZDQ4OGE3YzkxYzUyNzdmYjkzMDQ3ZTdhMmNjMWVkOGUzMzk2Yjg1NGZmIn0sIHsiaWQiOiAiMDA5YTFmMjkzMjUzZTQxZSIsICJhbW91bnQiOiAyLCAic2VjcmV0IjogImRlNTVjMTVmYWVmZGVkN2Y5Yzk5OWMzZDRjNjJmODFiMGM2ZmUyMWE3NTJmZGVmZjZiMDg0Y2YyZGYyZjVjZjMiLCAiQyI6ICIwMmRlNDBjNTlkOTAzODNiODg1M2NjZjNhNGIyMDg2NGFjODNiYTc1OGZjZTNkOTU5ZGJiODkzNjEwMDJlOGNlNDcifV0sICJtaW50IjogImh0dHA6Ly9sb2NhbGhvc3Q6MzMzOCJ9XX0='; const proofs = await wallet.receive(token3sat, { - preference: [{ amount: 1, count: 3 }] + outputAmounts: { keepAmounts: [], sendAmounts: [1, 1, 1] } }); expect(proofs).toHaveLength(3); @@ -212,103 +225,6 @@ describe('checkProofsSpent', () => { }); }); -describe('payLnInvoice', () => { - const proofs = [ - { - id: '009a1f293253e41e', - amount: 1, - secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a13', - C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be' - } - ]; - test('test payLnInvoice base case', async () => { - nock(mintUrl) - .get('/v1/melt/quote/bolt11/test') - .reply(200, { - quote: 'test_melt_quote_id', - amount: 2000, - fee_reserve: 20, - payment_preimage: null, - state: 'PAID' - } as MeltQuoteResponse); - nock(mintUrl) - .post('/v1/melt/bolt11') - .reply(200, { - quote: 'test_melt_quote_id', - amount: 2000, - fee_reserve: 20, - payment_preimage: null, - state: 'PAID' - } as MeltQuoteResponse); - - const wallet = new CashuWallet(mint, { unit }); - const meltQuote = await wallet.checkMeltQuote('test'); - - const result = await wallet.payLnInvoice(invoice, proofs, meltQuote); - - expect(result).toEqual({ isPaid: true, preimage: null, change: [] }); - }); - test('test payLnInvoice change', async () => { - nock.cleanAll(); - nock(mintUrl) - .get('/v1/keys') - .reply(200, { - keysets: [ - { - id: '009a1f293253e41e', - unit: 'sat', - keys: { - 1: '02f970b6ee058705c0dddc4313721cffb7efd3d142d96ea8e01d31c2b2ff09f181', - 2: '03361cd8bd1329fea797a6add1cf1990ffcf2270ceb9fc81eeee0e8e9c1bd0cdf5' - } - } - ] - }); - nock(mintUrl) - .get('/v1/melt/quote/bolt11/test') - .reply(200, { - quote: 'test_melt_quote_id', - amount: 2000, - fee_reserve: 20, - payment_preimage: 'asd', - state: 'PAID' - } as MeltQuoteResponse); - nock(mintUrl) - .post('/v1/melt/bolt11') - .reply(200, { - quote: 'test_melt_quote_id', - amount: 2000, - fee_reserve: 20, - payment_preimage: 'asd', - state: 'PAID', - change: [ - { - id: '009a1f293253e41e', - amount: 2, - C_: '0361a2725cfd88f60ded718378e8049a4a6cee32e214a9870b44c3ffea2dc9e625' - } - ] - }); - - const wallet = new CashuWallet(mint, { unit }); - const meltQuote = await wallet.checkMeltQuote('test'); - const result = await wallet.payLnInvoice(invoice, [{ ...proofs[0], amount: 3 }], meltQuote); - - expect(result.isPaid).toBe(true); - expect(result.preimage).toBe('asd'); - expect(result.change).toHaveLength(1); - }); - test('test payLnInvoice bad resonse', async () => { - nock(mintUrl).post('/v1/melt/bolt11').reply(200, {}); - const wallet = new CashuWallet(mint, { unit }); - const result = await wallet - .payLnInvoice(invoice, proofs, {} as MeltQuoteResponse) - .catch((e) => e); - - expect(result).toEqual(new Error('bad response')); - }); -}); - describe('requestTokens', () => { test('test requestTokens', async () => { nock(mintUrl) @@ -324,7 +240,7 @@ describe('requestTokens', () => { }); const wallet = new CashuWallet(mint, { unit }); - const { proofs } = await wallet.mintTokens(1, ''); + const { proofs } = await wallet.mintProofs(1, ''); expect(proofs).toHaveLength(1); expect(proofs[0]).toMatchObject({ amount: 1, id: '009a1f293253e41e' }); @@ -335,7 +251,7 @@ describe('requestTokens', () => { nock(mintUrl).post('/v1/mint/bolt11').reply(200, {}); const wallet = new CashuWallet(mint, { unit }); - const result = await wallet.mintTokens(1, '').catch((e) => e); + const result = await wallet.mintProofs(1, '').catch((e) => e); expect(result).toEqual(new Error('bad response')); }); @@ -352,7 +268,7 @@ describe('send', () => { ]; test('test send base case', async () => { nock(mintUrl) - .post('/split') + .post('/v1/swap') .reply(200, { signatures: [ { @@ -366,7 +282,7 @@ describe('send', () => { const result = await wallet.send(1, proofs); - expect(result.returnChange).toHaveLength(0); + expect(result.keep).toHaveLength(0); expect(result.send).toHaveLength(1); expect(result.send[0]).toMatchObject({ amount: 1, id: '009a1f293253e41e' }); expect(/[0-9a-f]{64}/.test(result.send[0].C)).toBe(true); @@ -404,10 +320,10 @@ describe('send', () => { expect(result.send[0]).toMatchObject({ amount: 1, id: '009a1f293253e41e' }); 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.returnChange).toHaveLength(1); - expect(result.returnChange[0]).toMatchObject({ amount: 1, id: '009a1f293253e41e' }); - expect(/[0-9a-f]{64}/.test(result.returnChange[0].C)).toBe(true); - expect(/[0-9a-f]{64}/.test(result.returnChange[0].secret)).toBe(true); + expect(result.keep).toHaveLength(1); + expect(result.keep[0]).toMatchObject({ amount: 1, id: '009a1f293253e41e' }); + expect(/[0-9a-f]{64}/.test(result.keep[0].C)).toBe(true); + expect(/[0-9a-f]{64}/.test(result.keep[0].secret)).toBe(true); }); test('test send over paying2', async () => { @@ -443,10 +359,10 @@ describe('send', () => { expect(result.send[0]).toMatchObject({ amount: 1, id: '009a1f293253e41e' }); 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.returnChange).toHaveLength(1); - expect(result.returnChange[0]).toMatchObject({ amount: 1, id: '009a1f293253e41e' }); - expect(/[0-9a-f]{64}/.test(result.returnChange[0].C)).toBe(true); - expect(/[0-9a-f]{64}/.test(result.returnChange[0].secret)).toBe(true); + expect(result.keep).toHaveLength(1); + expect(result.keep[0]).toMatchObject({ amount: 1, id: '009a1f293253e41e' }); + expect(/[0-9a-f]{64}/.test(result.keep[0].C)).toBe(true); + expect(/[0-9a-f]{64}/.test(result.keep[0].secret)).toBe(true); }); test('test send preference', async () => { nock(mintUrl) @@ -491,8 +407,10 @@ describe('send', () => { C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be' } ]; + await wallet.getKeys(); const result = await wallet.send(4, overpayProofs, { - preference: { sendPreference: [{ amount: 1, count: 4 }] } + // preference: { sendPreference: [{ amount: 1, count: 4 }] } + outputAmounts: { sendAmounts: [1, 1, 1, 1], keepAmounts: [] } }); expect(result.send).toHaveLength(4); @@ -502,7 +420,7 @@ describe('send', () => { expect(result.send[3]).toMatchObject({ amount: 1, id: '009a1f293253e41e' }); 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.returnChange).toHaveLength(0); + expect(result.keep).toHaveLength(0); }); test('test send preference overpay', async () => { @@ -548,8 +466,9 @@ describe('send', () => { C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be' } ]; - const result = await wallet.send(4, overpayProofs, { - preference: { sendPreference: [{ amount: 1, count: 3 }] } + await wallet.getKeys(); + const result = await wallet.send(3, overpayProofs, { + outputAmounts: { sendAmounts: [1, 1, 1], keepAmounts: [1] } }); expect(result.send).toHaveLength(3); @@ -558,8 +477,8 @@ describe('send', () => { expect(result.send[2]).toMatchObject({ amount: 1, id: '009a1f293253e41e' }); 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.returnChange).toHaveLength(1); - expect(result.returnChange[0]).toMatchObject({ amount: 1, id: '009a1f293253e41e' }); + expect(result.keep).toHaveLength(1); + expect(result.keep[0]).toMatchObject({ amount: 1, id: '009a1f293253e41e' }); }); test('test send not enough funds', async () => { @@ -578,7 +497,7 @@ describe('send', () => { const result = await wallet.send(2, proofs).catch((e) => e); - expect(result).toEqual(new Error('Not enough funds available')); + expect(result).toEqual(new Error('Not enough funds available to send')); }); test('test send bad response', async () => { nock(mintUrl).post('/v1/swap').reply(200, {}); @@ -602,12 +521,13 @@ describe('send', () => { describe('deterministic', () => { test('no seed', async () => { const wallet = new CashuWallet(mint); + await wallet.getKeys(); const result = await wallet .send( 1, [ { - id: 'z32vUtKgNCm1', + id: '009a1f293253e41e', amount: 2, secret: '1f98e6837a434644c9411825d7c6d6e13974b931f8f0652217cea29010674a13', C: '034268c0bd30b945adf578aca2dc0d1e26ef089869aaf9a08ba3a6da40fda1d8be'