From b13bd85215ad64e7a2764ac7e7fee5945ffa1c07 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Tue, 29 Aug 2023 09:02:16 +0200 Subject: taler-harness: remove axios usage, renovate some tests --- packages/taler-wallet-core/package.json | 1 - 1 file changed, 1 deletion(-) (limited to 'packages/taler-wallet-core') diff --git a/packages/taler-wallet-core/package.json b/packages/taler-wallet-core/package.json index 25e7c2eb2..c471770de 100644 --- a/packages/taler-wallet-core/package.json +++ b/packages/taler-wallet-core/package.json @@ -72,7 +72,6 @@ "@gnu-taler/idb-bridge": "workspace:*", "@gnu-taler/taler-util": "workspace:*", "@types/node": "^18.11.17", - "axios": "^0.27.2", "big-integer": "^1.6.51", "fflate": "^0.7.4", "tslib": "^2.5.3" -- cgit v1.2.3 From 55bdc161b58ddf4f24e32dff9acd8011a4364327 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Tue, 29 Aug 2023 09:45:45 +0200 Subject: taler-harness: add exchange-purse test --- .../src/integrationtests/test-exchange-purse.ts | 224 +++++++++++++++++++++ .../src/integrationtests/testrunner.ts | 9 +- packages/taler-util/src/payto.ts | 22 ++ packages/taler-util/src/taler-crypto.ts | 5 +- packages/taler-wallet-core/src/index.ts | 8 +- .../src/operations/pay-peer-common.ts | 22 -- .../src/operations/pay-peer-pull-credit.ts | 2 +- .../src/operations/pay-peer-push-credit.ts | 2 +- 8 files changed, 261 insertions(+), 33 deletions(-) create mode 100644 packages/taler-harness/src/integrationtests/test-exchange-purse.ts (limited to 'packages/taler-wallet-core') diff --git a/packages/taler-harness/src/integrationtests/test-exchange-purse.ts b/packages/taler-harness/src/integrationtests/test-exchange-purse.ts new file mode 100644 index 000000000..5a1d02692 --- /dev/null +++ b/packages/taler-harness/src/integrationtests/test-exchange-purse.ts @@ -0,0 +1,224 @@ +/* + This file is part of GNU Taler + (C) 2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +/** + * Imports. + */ +import { + AbsoluteTime, + ContractTermsUtil, + decodeCrock, + Duration, + encodeCrock, + getRandomBytes, + hash, + j2s, + PeerContractTerms, + TalerError, + TalerPreciseTimestamp, +} from "@gnu-taler/taler-util"; +import { + checkReserve, + CryptoDispatcher, + downloadExchangeInfo, + EncryptContractRequest, + findDenomOrThrow, + SpendCoinDetails, + SynchronousCryptoWorkerFactoryPlain, + topupReserveWithDemobank, + Wallet, + withdrawCoin, +} from "@gnu-taler/taler-wallet-core"; +import { GlobalTestState, harnessHttpLib } from "../harness/harness.js"; +import { createSimpleTestkudosEnvironmentV2 } from "../harness/helpers.js"; + +/** + * Test the exchange's purse API. + */ +export async function runExchangePurseTest(t: GlobalTestState) { + // Set up test environment + + const { bank, exchange } = await createSimpleTestkudosEnvironmentV2(t); + + const http = harnessHttpLib; + const cryptoDisp = new CryptoDispatcher( + new SynchronousCryptoWorkerFactoryPlain(), + ); + const cryptoApi = cryptoDisp.cryptoApi; + + try { + // Withdraw digital cash into the wallet. + + const exchangeInfo = await downloadExchangeInfo(exchange.baseUrl, http); + + const reserveKeyPair = await cryptoApi.createEddsaKeypair({}); + + let reserveUrl = new URL( + `reserves/${reserveKeyPair.pub}`, + exchange.baseUrl, + ); + reserveUrl.searchParams.set("timeout_ms", "30000"); + const longpollReq = http.fetch(reserveUrl.href, { + method: "GET", + }); + + await topupReserveWithDemobank({ + amount: "TESTKUDOS:10", + http, + reservePub: reserveKeyPair.pub, + bankAccessApiBaseUrl: bank.bankAccessApiBaseUrl, + exchangeInfo, + }); + + console.log("waiting for longpoll request"); + const resp = await longpollReq; + console.log(`got response, status ${resp.status}`); + + console.log(exchangeInfo); + + await checkReserve(http, exchange.baseUrl, reserveKeyPair.pub); + + const d1 = findDenomOrThrow(exchangeInfo, "TESTKUDOS:8", { + denomselAllowLate: Wallet.defaultConfig.testing.denomselAllowLate, + }); + + const coin = await withdrawCoin({ + http, + cryptoApi, + reserveKeyPair: { + reservePriv: reserveKeyPair.priv, + reservePub: reserveKeyPair.pub, + }, + denom: d1, + exchangeBaseUrl: exchange.baseUrl, + }); + + const amount = "TESTKUDOS:5"; + const purseFee = "TESTKUDOS:0"; + + const mergeTimestamp = TalerPreciseTimestamp.now(); + + const contractTerms: PeerContractTerms = { + amount, + summary: "Hello", + purse_expiration: AbsoluteTime.toProtocolTimestamp( + AbsoluteTime.addDuration( + AbsoluteTime.now(), + Duration.fromSpec({ minutes: 1 }), + ), + ), + }; + + const mergeReservePair = await cryptoApi.createEddsaKeypair({}); + const pursePair = await cryptoApi.createEddsaKeypair({}); + const mergePair = await cryptoApi.createEddsaKeypair({}); + const contractPair = await cryptoApi.createEddsaKeypair({}); + const contractEncNonce = encodeCrock(getRandomBytes(24)); + + const pursePub = pursePair.pub; + + const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms); + + const purseSigResp = await cryptoApi.signPurseCreation({ + hContractTerms, + mergePub: mergePair.pub, + minAge: 0, + purseAmount: amount, + purseExpiration: contractTerms.purse_expiration, + pursePriv: pursePair.priv, + }); + + const coinSpend: SpendCoinDetails = { + ageCommitmentProof: undefined, + coinPriv: coin.coinPriv, + coinPub: coin.coinPub, + contribution: amount, + denomPubHash: coin.denomPubHash, + denomSig: coin.denomSig, + }; + + const depositSigsResp = await cryptoApi.signPurseDeposits({ + exchangeBaseUrl: exchange.baseUrl, + pursePub: pursePair.pub, + coins: [coinSpend], + }); + + const encryptContractRequest: EncryptContractRequest = { + contractTerms: contractTerms, + mergePriv: mergePair.priv, + pursePriv: pursePair.priv, + pursePub: pursePair.pub, + contractPriv: contractPair.priv, + contractPub: contractPair.pub, + nonce: contractEncNonce, + }; + + const econtractResp = await cryptoApi.encryptContractForMerge( + encryptContractRequest, + ); + + const econtractHash = encodeCrock( + hash(decodeCrock(econtractResp.econtract.econtract)), + ); + + const createPurseUrl = new URL( + `purses/${pursePair.pub}/create`, + exchange.baseUrl, + ); + + const reqBody = { + amount: amount, + merge_pub: mergePair.pub, + purse_sig: purseSigResp.sig, + h_contract_terms: hContractTerms, + purse_expiration: contractTerms.purse_expiration, + deposits: depositSigsResp.deposits, + min_age: 0, + econtract: econtractResp.econtract, + }; + + const httpResp = await http.fetch(createPurseUrl.href, { + method: "POST", + body: reqBody, + }); + + const respBody = await httpResp.json(); + + console.log("status", httpResp.status); + + console.log(j2s(respBody)); + + const mergeUrl = new URL(`purses/${pursePub}/merge`, exchange.baseUrl); + mergeUrl.searchParams.set("timeout_ms", "300"); + const statusResp = await http.fetch(mergeUrl.href, {}); + + const statusRespBody = await statusResp.json(); + + console.log(j2s(statusRespBody)); + + t.assertTrue(statusRespBody.merge_timestamp === undefined); + } catch (e) { + if (e instanceof TalerError) { + console.log(e); + console.log(j2s(e.errorDetail)); + } else { + console.log(e); + } + throw e; + } +} + +runExchangePurseTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts index cbdca04b9..226fd6b09 100644 --- a/packages/taler-harness/src/integrationtests/testrunner.ts +++ b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -14,7 +14,12 @@ GNU Taler; see the file COPYING. If not, see */ -import { CancellationToken, Logger, minimatch, setGlobalLogLevelFromString } from "@gnu-taler/taler-util"; +import { + CancellationToken, + Logger, + minimatch, + setGlobalLogLevelFromString, +} from "@gnu-taler/taler-util"; import * as child_process from "child_process"; import * as fs from "fs"; import * as os from "os"; @@ -105,6 +110,7 @@ import { runPeerRepairTest } from "./test-peer-repair.js"; import { runPaymentShareTest } from "./test-payment-share.js"; import { runSimplePaymentTest } from "./test-simple-payment.js"; import { runTermOfServiceFormatTest } from "./test-tos-format.js"; +import { runExchangePurseTest } from "./test-exchange-purse.js"; /** * Test runner. @@ -137,6 +143,7 @@ const allTests: TestMainFunction[] = [ runFeeRegressionTest, runForcedSelectionTest, runKycTest, + runExchangePurseTest, runExchangeDepositTest, runLibeufinAnastasisFacadeTest, runLibeufinApiBankaccountTest, diff --git a/packages/taler-util/src/payto.ts b/packages/taler-util/src/payto.ts index dd35b44be..2b0af4cc2 100644 --- a/packages/taler-util/src/payto.ts +++ b/packages/taler-util/src/payto.ts @@ -239,3 +239,25 @@ export function parsePaytoUri(s: string): PaytoUri | undefined { isKnown: false, }; } + +export function talerPaytoFromExchangeReserve( + exchangeBaseUrl: string, + reservePub: string, +): string { + const url = new URL(exchangeBaseUrl); + let proto: string; + if (url.protocol === "http:") { + proto = "taler-reserve-http"; + } else if (url.protocol === "https:") { + proto = "taler-reserve"; + } else { + throw Error(`unsupported exchange base URL protocol (${url.protocol})`); + } + + let path = url.pathname; + if (!path.endsWith("/")) { + path = path + "/"; + } + + return `payto://${proto}/${url.host}${url.pathname}${reservePub}`; +} diff --git a/packages/taler-util/src/taler-crypto.ts b/packages/taler-util/src/taler-crypto.ts index d52edc1e5..9425a9320 100644 --- a/packages/taler-util/src/taler-crypto.ts +++ b/packages/taler-util/src/taler-crypto.ts @@ -1004,7 +1004,7 @@ export enum TalerSignaturePurpose { SYNC_BACKUP_UPLOAD = 1450, } -export const enum WalletAccountMergeFlags { +export enum WalletAccountMergeFlags { /** * Not a legal mode! */ @@ -1281,7 +1281,8 @@ export namespace AgeRestriction { } const PublishedAgeRestrictionBaseKey: Edx25519PublicKey = decodeCrock( - "CH0VKFDZ2GWRWHQBBGEK9MWV5YDQVJ0RXEE0KYT3NMB69F0R96TG"); + "CH0VKFDZ2GWRWHQBBGEK9MWV5YDQVJ0RXEE0KYT3NMB69F0R96TG", + ); export async function restrictionCommitSeeded( ageMask: number, diff --git a/packages/taler-wallet-core/src/index.ts b/packages/taler-wallet-core/src/index.ts index 8dd06fe2b..d64f7d5e6 100644 --- a/packages/taler-wallet-core/src/index.ts +++ b/packages/taler-wallet-core/src/index.ts @@ -51,12 +51,8 @@ export * from "./operations/refresh.js"; export * from "./dbless.js"; -export { - nativeCryptoR, - nativeCrypto, - nullCrypto, - TalerCryptoInterface, -} from "./crypto/cryptoImplementation.js"; +export * from "./crypto/cryptoTypes.js"; +export * from "./crypto/cryptoImplementation.js"; export * from "./util/timer.js"; export * from "./util/denominations.js"; diff --git a/packages/taler-wallet-core/src/operations/pay-peer-common.ts b/packages/taler-wallet-core/src/operations/pay-peer-common.ts index 1bc2e8d49..4fdfecb4d 100644 --- a/packages/taler-wallet-core/src/operations/pay-peer-common.ts +++ b/packages/taler-wallet-core/src/operations/pay-peer-common.ts @@ -420,28 +420,6 @@ export const codecForExchangePurseStatus = (): Codec => .property("merge_timestamp", codecOptional(codecForTimestamp)) .build("ExchangePurseStatus"); -export function talerPaytoFromExchangeReserve( - exchangeBaseUrl: string, - reservePub: string, -): string { - const url = new URL(exchangeBaseUrl); - let proto: string; - if (url.protocol === "http:") { - proto = "taler-reserve-http"; - } else if (url.protocol === "https:") { - proto = "taler-reserve"; - } else { - throw Error(`unsupported exchange base URL protocol (${url.protocol})`); - } - - let path = url.pathname; - if (!path.endsWith("/")) { - path = path + "/"; - } - - return `payto://${proto}/${url.host}${url.pathname}${reservePub}`; -} - export async function getMergeReserveInfo( ws: InternalWalletState, req: { diff --git a/packages/taler-wallet-core/src/operations/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/operations/pay-peer-pull-credit.ts index 88b441cdd..954300264 100644 --- a/packages/taler-wallet-core/src/operations/pay-peer-pull-credit.ts +++ b/packages/taler-wallet-core/src/operations/pay-peer-pull-credit.ts @@ -45,6 +45,7 @@ import { j2s, makeErrorDetail, stringifyTalerUri, + talerPaytoFromExchangeReserve, } from "@gnu-taler/taler-util"; import { readSuccessResponseJsonOrErrorCode, @@ -74,7 +75,6 @@ import { import { codecForExchangePurseStatus, getMergeReserveInfo, - talerPaytoFromExchangeReserve, } from "./pay-peer-common.js"; import { constructTransactionIdentifier, diff --git a/packages/taler-wallet-core/src/operations/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/operations/pay-peer-push-credit.ts index e76b934fa..c552d63f0 100644 --- a/packages/taler-wallet-core/src/operations/pay-peer-push-credit.ts +++ b/packages/taler-wallet-core/src/operations/pay-peer-push-credit.ts @@ -47,6 +47,7 @@ import { j2s, makeErrorDetail, parsePayPushUri, + talerPaytoFromExchangeReserve, } from "@gnu-taler/taler-util"; import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; import { @@ -71,7 +72,6 @@ import { updateExchangeFromUrl } from "./exchanges.js"; import { codecForExchangePurseStatus, getMergeReserveInfo, - talerPaytoFromExchangeReserve, } from "./pay-peer-common.js"; import { TransitionInfo, -- cgit v1.2.3 From ebb1c58e7a2be01e42f35dfe6d890a08cf992c79 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Tue, 29 Aug 2023 13:44:50 +0200 Subject: wallet-core: remove usage of /wire --- .../integrationtests/test-exchange-timetravel.ts | 86 ++++++++--- .../test-merchant-spec-public-orders.ts | 74 ++++----- packages/taler-harness/src/lint.ts | 6 +- packages/taler-util/src/taler-types.ts | 135 +++++++++++++--- packages/taler-wallet-core/src/bank-api-client.ts | 2 +- packages/taler-wallet-core/src/db.ts | 1 + packages/taler-wallet-core/src/dbless.ts | 9 +- packages/taler-wallet-core/src/dev-experiments.ts | 2 +- .../src/operations/backup/index.ts | 2 +- .../taler-wallet-core/src/operations/exchanges.ts | 170 +++++++++------------ .../taler-wallet-core/src/operations/merchants.ts | 2 +- .../src/operations/pay-peer-pull-debit.ts | 2 +- .../src/operations/pay-peer-push-credit.ts | 2 +- .../taler-wallet-core/src/operations/recoup.ts | 2 +- .../taler-wallet-core/src/operations/reward.ts | 2 +- .../taler-wallet-core/src/operations/testing.ts | 2 +- .../taler-wallet-core/src/operations/withdraw.ts | 6 +- 17 files changed, 317 insertions(+), 188 deletions(-) (limited to 'packages/taler-wallet-core') diff --git a/packages/taler-harness/src/integrationtests/test-exchange-timetravel.ts b/packages/taler-harness/src/integrationtests/test-exchange-timetravel.ts index dee00d1ff..d8f8767e6 100644 --- a/packages/taler-harness/src/integrationtests/test-exchange-timetravel.ts +++ b/packages/taler-harness/src/integrationtests/test-exchange-timetravel.ts @@ -19,10 +19,16 @@ */ import { AbsoluteTime, + Amounts, codecForExchangeKeysJson, DenominationPubKey, + DenomKeyType, Duration, durationFromSpec, + encodeCrock, + ExchangeKeysJson, + hashDenomPub, + Logger, } from "@gnu-taler/taler-util"; import { createPlatformHttpLib, @@ -40,6 +46,52 @@ import { } from "../harness/harness.js"; import { withdrawViaBank } from "../harness/helpers.js"; +const logger = new Logger("test-exchange-timetravel.ts"); + +interface DenomInfo { + denomPub: DenominationPubKey; + expireDeposit: string; +} + +function getDenomInfoFromKeys(ek: ExchangeKeysJson): DenomInfo[] { + const denomInfos: DenomInfo[] = []; + for (const denomGroup of ek.denominations) { + switch (denomGroup.cipher) { + case "RSA": + case "RSA+age_restricted": { + let ageMask = 0; + if (denomGroup.cipher === "RSA+age_restricted") { + ageMask = denomGroup.age_mask; + } + for (const denomIn of denomGroup.denoms) { + const denomPub: DenominationPubKey = { + age_mask: ageMask, + cipher: DenomKeyType.Rsa, + rsa_public_key: denomIn.rsa_pub, + }; + denomInfos.push({ + denomPub, + expireDeposit: AbsoluteTime.stringify( + AbsoluteTime.fromProtocolTimestamp(denomIn.stamp_expire_deposit), + ), + }); + } + break; + } + case "CS+age_restricted": + case "CS": + logger.warn("Clause-Schnorr denominations not supported"); + continue; + default: + logger.warn( + `denomination type ${(denomGroup as any).cipher} not supported`, + ); + continue; + } + } + return denomInfos; +} + async function applyTimeTravel( timetravelDuration: Duration, s: { @@ -144,7 +196,7 @@ export async function runExchangeTimetravelTest(t: GlobalTestState) { await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:15" }); - const keysResp1 = await http.get(exchange.baseUrl + "keys"); + const keysResp1 = await http.fetch(exchange.baseUrl + "keys"); const keys1 = await readSuccessResponseJsonOrThrow( keysResp1, codecForExchangeKeysJson(), @@ -163,7 +215,7 @@ export async function runExchangeTimetravelTest(t: GlobalTestState) { merchant, }); - const keysResp2 = await http.get(exchange.baseUrl + "keys"); + const keysResp2 = await http.fetch(exchange.baseUrl + "keys"); const keys2 = await readSuccessResponseJsonOrThrow( keysResp2, codecForExchangeKeysJson(), @@ -173,41 +225,31 @@ export async function runExchangeTimetravelTest(t: GlobalTestState) { JSON.stringify(keys2, undefined, 2), ); - const denomPubs1 = keys1.denoms.map((x) => { - return { - denomPub: x.denom_pub, - expireDeposit: AbsoluteTime.stringify( - AbsoluteTime.fromProtocolTimestamp(x.stamp_expire_deposit), - ), - }; - }); + const denomPubs1 = getDenomInfoFromKeys(keys1); + const denomPubs2 = getDenomInfoFromKeys(keys2); - const denomPubs2 = keys2.denoms.map((x) => { - return { - denomPub: x.denom_pub, - expireDeposit: AbsoluteTime.stringify( - AbsoluteTime.fromProtocolTimestamp(x.stamp_expire_deposit), - ), - }; - }); const dps2 = new Set(denomPubs2.map((x) => x.denomPub)); console.log("=== KEYS RESPONSE 1 ==="); console.log( "list issue date", - AbsoluteTime.stringify(AbsoluteTime.fromProtocolTimestamp(keys1.list_issue_date)), + AbsoluteTime.stringify( + AbsoluteTime.fromProtocolTimestamp(keys1.list_issue_date), + ), ); - console.log("num denoms", keys1.denoms.length); + console.log("num denoms", denomPubs1.length); console.log("denoms", JSON.stringify(denomPubs1, undefined, 2)); console.log("=== KEYS RESPONSE 2 ==="); console.log( "list issue date", - AbsoluteTime.stringify(AbsoluteTime.fromProtocolTimestamp(keys2.list_issue_date)), + AbsoluteTime.stringify( + AbsoluteTime.fromProtocolTimestamp(keys2.list_issue_date), + ), ); - console.log("num denoms", keys2.denoms.length); + console.log("num denoms", denomPubs2.length); console.log("denoms", JSON.stringify(denomPubs2, undefined, 2)); for (const da of denomPubs1) { diff --git a/packages/taler-harness/src/integrationtests/test-merchant-spec-public-orders.ts b/packages/taler-harness/src/integrationtests/test-merchant-spec-public-orders.ts index fca368dad..e959e813b 100644 --- a/packages/taler-harness/src/integrationtests/test-merchant-spec-public-orders.ts +++ b/packages/taler-harness/src/integrationtests/test-merchant-spec-public-orders.ts @@ -72,7 +72,7 @@ async function testWithClaimToken( let talerPayUri: string; { - const httpResp = await httpLib.get( + const httpResp = await httpLib.fetch( new URL(`orders/${orderId}`, merchantBaseUrl).href, ); const r = await httpResp.json(); @@ -83,7 +83,7 @@ async function testWithClaimToken( { const url = new URL(`orders/${orderId}`, merchantBaseUrl); url.searchParams.set("token", claimToken); - const httpResp = await httpLib.get(url.href); + const httpResp = await httpLib.fetch(url.href); const r = await httpResp.json(); t.assertDeepEqual(httpResp.status, 402); console.log(r); @@ -94,7 +94,7 @@ async function testWithClaimToken( { const url = new URL(`orders/${orderId}`, merchantBaseUrl); url.searchParams.set("token", claimToken); - const httpResp = await httpLib.get(url.href, { + const httpResp = await httpLib.fetch(url.href, { headers: { Accept: "text/html", }, @@ -120,7 +120,7 @@ async function testWithClaimToken( const url = new URL(`orders/${orderId}`, merchantBaseUrl); const hcWrong = encodeCrock(getRandomBytes(64)); url.searchParams.set("h_contract", hcWrong); - const httpResp = await httpLib.get(url.href); + const httpResp = await httpLib.fetch(url.href); const r = await httpResp.json(); console.log(r); t.assertDeepEqual(httpResp.status, 403); @@ -131,7 +131,7 @@ async function testWithClaimToken( const url = new URL(`orders/${orderId}`, merchantBaseUrl); const ctWrong = encodeCrock(getRandomBytes(16)); url.searchParams.set("token", ctWrong); - const httpResp = await httpLib.get(url.href); + const httpResp = await httpLib.fetch(url.href); const r = await httpResp.json(); console.log(r); t.assertDeepEqual(httpResp.status, 403); @@ -141,7 +141,7 @@ async function testWithClaimToken( { const url = new URL(`orders/${orderId}`, merchantBaseUrl); url.searchParams.set("token", claimToken); - const httpResp = await httpLib.get(url.href); + const httpResp = await httpLib.fetch(url.href); const r = await httpResp.json(); console.log(r); t.assertDeepEqual(httpResp.status, 402); @@ -151,7 +151,7 @@ async function testWithClaimToken( { const url = new URL(`orders/${orderId}`, merchantBaseUrl); url.searchParams.set("h_contract", contractTermsHash); - const httpResp = await httpLib.get(url.href); + const httpResp = await httpLib.fetch(url.href); const r = await httpResp.json(); console.log(r); t.assertDeepEqual(httpResp.status, 402); @@ -160,7 +160,7 @@ async function testWithClaimToken( // claimed, unpaid, access without credentials { const url = new URL(`orders/${orderId}`, merchantBaseUrl); - const httpResp = await httpLib.get(url.href); + const httpResp = await httpLib.fetch(url.href); const r = await httpResp.json(); console.log(r); t.assertDeepEqual(httpResp.status, 202); @@ -178,7 +178,7 @@ async function testWithClaimToken( // paid, access without credentials { const url = new URL(`orders/${orderId}`, merchantBaseUrl); - const httpResp = await httpLib.get(url.href); + const httpResp = await httpLib.fetch(url.href); const r = await httpResp.json(); console.log(r); t.assertDeepEqual(httpResp.status, 202); @@ -189,7 +189,7 @@ async function testWithClaimToken( const url = new URL(`orders/${orderId}`, merchantBaseUrl); const hcWrong = encodeCrock(getRandomBytes(64)); url.searchParams.set("h_contract", hcWrong); - const httpResp = await httpLib.get(url.href); + const httpResp = await httpLib.fetch(url.href); const r = await httpResp.json(); console.log(r); t.assertDeepEqual(httpResp.status, 403); @@ -200,7 +200,7 @@ async function testWithClaimToken( const url = new URL(`orders/${orderId}`, merchantBaseUrl); const ctWrong = encodeCrock(getRandomBytes(16)); url.searchParams.set("token", ctWrong); - const httpResp = await httpLib.get(url.href); + const httpResp = await httpLib.fetch(url.href); const r = await httpResp.json(); console.log(r); t.assertDeepEqual(httpResp.status, 403); @@ -210,7 +210,7 @@ async function testWithClaimToken( { const url = new URL(`orders/${orderId}`, merchantBaseUrl); url.searchParams.set("h_contract", contractTermsHash); - const httpResp = await httpLib.get(url.href); + const httpResp = await httpLib.fetch(url.href); const r = await httpResp.json(); console.log(r); t.assertDeepEqual(httpResp.status, 200); @@ -220,7 +220,7 @@ async function testWithClaimToken( { const url = new URL(`orders/${orderId}`, merchantBaseUrl); url.searchParams.set("token", claimToken); - const httpResp = await httpLib.get(url.href); + const httpResp = await httpLib.fetch(url.href); const r = await httpResp.json(); console.log(r); t.assertDeepEqual(httpResp.status, 200); @@ -232,7 +232,7 @@ async function testWithClaimToken( { const url = new URL(`orders/${orderId}`, merchantBaseUrl); url.searchParams.set("token", claimToken); - const httpResp = await httpLib.get(url.href, { + const httpResp = await httpLib.fetch(url.href, { headers: { Accept: "text/html" }, }); t.assertDeepEqual(httpResp.status, 200); @@ -269,7 +269,7 @@ async function testWithClaimToken( { const url = new URL(`orders/${apOrderId}`, merchantBaseUrl); url.searchParams.set("token", apToken); - const httpResp = await httpLib.get(url.href); + const httpResp = await httpLib.fetch(url.href); const r = await httpResp.json(); console.log(r); t.assertDeepEqual(httpResp.status, 402); @@ -280,7 +280,7 @@ async function testWithClaimToken( const url = new URL(`orders/${apOrderId}`, merchantBaseUrl); url.searchParams.set("token", apToken); url.searchParams.set("session_id", sessionId); - const httpResp = await httpLib.get(url.href); + const httpResp = await httpLib.fetch(url.href); const r = await httpResp.json(); console.log(r); t.assertDeepEqual(httpResp.status, 402); @@ -293,7 +293,7 @@ async function testWithClaimToken( const url = new URL(`orders/${apOrderId}`, merchantBaseUrl); url.searchParams.set("token", apToken); url.searchParams.set("session_id", sessionId); - const httpResp = await httpLib.get(url.href, { + const httpResp = await httpLib.fetch(url.href, { headers: { Accept: "text/html" }, }); t.assertDeepEqual(httpResp.status, 302); @@ -326,7 +326,7 @@ async function testWithoutClaimToken( let talerPayUri: string; { - const httpResp = await httpLib.get( + const httpResp = await httpLib.fetch( new URL(`orders/${orderId}`, merchantBaseUrl).href, ); const r = await httpResp.json(); @@ -336,7 +336,7 @@ async function testWithoutClaimToken( { const url = new URL(`orders/${orderId}`, merchantBaseUrl); - const httpResp = await httpLib.get(url.href); + const httpResp = await httpLib.fetch(url.href); const r = await httpResp.json(); t.assertDeepEqual(httpResp.status, 402); console.log(r); @@ -346,7 +346,7 @@ async function testWithoutClaimToken( { const url = new URL(`orders/${orderId}`, merchantBaseUrl); - const httpResp = await httpLib.get(url.href, { + const httpResp = await httpLib.fetch(url.href, { headers: { Accept: "text/html", }, @@ -374,7 +374,7 @@ async function testWithoutClaimToken( const url = new URL(`orders/${orderId}`, merchantBaseUrl); const hcWrong = encodeCrock(getRandomBytes(64)); url.searchParams.set("h_contract", hcWrong); - const httpResp = await httpLib.get(url.href); + const httpResp = await httpLib.fetch(url.href); const r = await httpResp.json(); console.log(r); t.assertDeepEqual(httpResp.status, 403); @@ -385,7 +385,7 @@ async function testWithoutClaimToken( const url = new URL(`orders/${orderId}`, merchantBaseUrl); const ctWrong = encodeCrock(getRandomBytes(16)); url.searchParams.set("token", ctWrong); - const httpResp = await httpLib.get(url.href); + const httpResp = await httpLib.fetch(url.href); const r = await httpResp.json(); console.log(r); t.assertDeepEqual(httpResp.status, 403); @@ -394,7 +394,7 @@ async function testWithoutClaimToken( // claimed, unpaid, no claim token { const url = new URL(`orders/${orderId}`, merchantBaseUrl); - const httpResp = await httpLib.get(url.href); + const httpResp = await httpLib.fetch(url.href); const r = await httpResp.json(); console.log(r); t.assertDeepEqual(httpResp.status, 402); @@ -404,7 +404,7 @@ async function testWithoutClaimToken( { const url = new URL(`orders/${orderId}`, merchantBaseUrl); url.searchParams.set("h_contract", contractTermsHash); - const httpResp = await httpLib.get(url.href); + const httpResp = await httpLib.fetch(url.href); const r = await httpResp.json(); console.log(r); t.assertDeepEqual(httpResp.status, 402); @@ -413,7 +413,7 @@ async function testWithoutClaimToken( // claimed, unpaid, access without credentials { const url = new URL(`orders/${orderId}`, merchantBaseUrl); - const httpResp = await httpLib.get(url.href); + const httpResp = await httpLib.fetch(url.href); const r = await httpResp.json(); console.log(r); // No credentials, but the order doesn't require a claim token. @@ -434,7 +434,7 @@ async function testWithoutClaimToken( // paid, access without credentials { const url = new URL(`orders/${orderId}`, merchantBaseUrl); - const httpResp = await httpLib.get(url.href); + const httpResp = await httpLib.fetch(url.href); const r = await httpResp.json(); console.log(r); t.assertDeepEqual(httpResp.status, 200); @@ -445,7 +445,7 @@ async function testWithoutClaimToken( const url = new URL(`orders/${orderId}`, merchantBaseUrl); const hcWrong = encodeCrock(getRandomBytes(64)); url.searchParams.set("h_contract", hcWrong); - const httpResp = await httpLib.get(url.href); + const httpResp = await httpLib.fetch(url.href); const r = await httpResp.json(); console.log(r); t.assertDeepEqual(httpResp.status, 403); @@ -456,7 +456,7 @@ async function testWithoutClaimToken( const url = new URL(`orders/${orderId}`, merchantBaseUrl); const ctWrong = encodeCrock(getRandomBytes(16)); url.searchParams.set("token", ctWrong); - const httpResp = await httpLib.get(url.href); + const httpResp = await httpLib.fetch(url.href); const r = await httpResp.json(); console.log(r); t.assertDeepEqual(httpResp.status, 403); @@ -466,7 +466,7 @@ async function testWithoutClaimToken( { const url = new URL(`orders/${orderId}`, merchantBaseUrl); url.searchParams.set("h_contract", contractTermsHash); - const httpResp = await httpLib.get(url.href); + const httpResp = await httpLib.fetch(url.href); const r = await httpResp.json(); console.log(r); t.assertDeepEqual(httpResp.status, 200); @@ -475,7 +475,7 @@ async function testWithoutClaimToken( // paid, JSON { const url = new URL(`orders/${orderId}`, merchantBaseUrl); - const httpResp = await httpLib.get(url.href); + const httpResp = await httpLib.fetch(url.href); const r = await httpResp.json(); console.log(r); t.assertDeepEqual(httpResp.status, 200); @@ -486,7 +486,7 @@ async function testWithoutClaimToken( // paid, HTML { const url = new URL(`orders/${orderId}`, merchantBaseUrl); - const httpResp = await httpLib.get(url.href, { + const httpResp = await httpLib.fetch(url.href, { headers: { Accept: "text/html" }, }); t.assertDeepEqual(httpResp.status, 200); @@ -523,7 +523,7 @@ async function testWithoutClaimToken( { const url = new URL(`orders/${apOrderId}`, merchantBaseUrl); url.searchParams.set("token", apToken); - const httpResp = await httpLib.get(url.href); + const httpResp = await httpLib.fetch(url.href); const r = await httpResp.json(); console.log(r); t.assertDeepEqual(httpResp.status, 402); @@ -534,7 +534,7 @@ async function testWithoutClaimToken( const url = new URL(`orders/${apOrderId}`, merchantBaseUrl); url.searchParams.set("token", apToken); url.searchParams.set("session_id", sessionId); - const httpResp = await httpLib.get(url.href); + const httpResp = await httpLib.fetch(url.href); const r = await httpResp.json(); console.log(r); t.assertDeepEqual(httpResp.status, 402); @@ -547,7 +547,7 @@ async function testWithoutClaimToken( const url = new URL(`orders/${apOrderId}`, merchantBaseUrl); url.searchParams.set("token", apToken); url.searchParams.set("session_id", sessionId); - const httpResp = await httpLib.get(url.href, { + const httpResp = await httpLib.fetch(url.href, { headers: { Accept: "text/html" }, }); t.assertDeepEqual(httpResp.status, 302); @@ -572,14 +572,14 @@ export async function runMerchantSpecPublicOrdersTest(t: GlobalTestState) { const merchantBaseUrl = merchant.makeInstanceBaseUrl(); { - const httpResp = await httpLib.get(new URL("config", merchantBaseUrl).href); + const httpResp = await httpLib.fetch(new URL("config", merchantBaseUrl).href); const r = await httpResp.json(); console.log(r); t.assertDeepEqual(r.currency, "TESTKUDOS"); } { - const httpResp = await httpLib.get( + const httpResp = await httpLib.fetch( new URL("orders/foo", merchantBaseUrl).href, ); const r = await httpResp.json(); @@ -589,7 +589,7 @@ export async function runMerchantSpecPublicOrdersTest(t: GlobalTestState) { } { - const httpResp = await httpLib.get( + const httpResp = await httpLib.fetch( new URL("orders/foo", merchantBaseUrl).href, { headers: { diff --git a/packages/taler-harness/src/lint.ts b/packages/taler-harness/src/lint.ts index f13049710..6d8e679db 100644 --- a/packages/taler-harness/src/lint.ts +++ b/packages/taler-harness/src/lint.ts @@ -407,7 +407,7 @@ export async function checkExchangeHttpd( { const mgmtUrl = new URL("management/keys", baseUrl); - const resp = await httpLib.get(mgmtUrl.href); + const resp = await httpLib.fetch(mgmtUrl.href); const futureKeys = await readSuccessResponseJsonOrThrow( resp, @@ -431,7 +431,7 @@ export async function checkExchangeHttpd( { const keysUrl = new URL("keys", baseUrl); - const resp = await Promise.race([httpLib.get(keysUrl.href), delayMs(2000)]); + const resp = await Promise.race([httpLib.fetch(keysUrl.href), delayMs(2000)]); if (!resp) { context.numErr++; @@ -467,7 +467,7 @@ export async function checkExchangeHttpd( { const keysUrl = new URL("wire", baseUrl); - const resp = await Promise.race([httpLib.get(keysUrl.href), delayMs(2000)]); + const resp = await Promise.race([httpLib.fetch(keysUrl.href), delayMs(2000)]); if (!resp) { context.numErr++; diff --git a/packages/taler-util/src/taler-types.ts b/packages/taler-util/src/taler-types.ts index 4d4a60d91..a78df7452 100644 --- a/packages/taler-util/src/taler-types.ts +++ b/packages/taler-util/src/taler-types.ts @@ -25,7 +25,7 @@ * Imports. */ -import { codecForAmountString } from "./amounts.js"; +import { Amounts, codecForAmountString } from "./amounts.js"; import { buildCodecForObject, buildCodecForUnion, @@ -719,16 +719,12 @@ export class ExchangeSignKeyJson { * Structure that the exchange gives us in /keys. */ export class ExchangeKeysJson { - /** * Canonical, public base URL of the exchange. */ base_url: string; - /** - * List of offered denominations. - */ - denoms: ExchangeDenomination[]; + currency: string; /** * The exchange's master public key. @@ -764,6 +760,111 @@ export class ExchangeKeysJson { reserve_closing_delay: TalerProtocolDuration; global_fees: GlobalFees[]; + + accounts: AccountInfo[]; + + wire_fees: { [methodName: string]: WireFeesJson[] }; + + denominations: DenomGroup[]; +} + +export type DenomGroup = + | DenomGroupRsa + | DenomGroupCs + | DenomGroupRsaAgeRestricted + | DenomGroupCsAgeRestricted; + +export interface DenomGroupCommon { + // How much are coins of this denomination worth? + value: AmountString; + + // Fee charged by the exchange for withdrawing a coin of this denomination. + fee_withdraw: AmountString; + + // Fee charged by the exchange for depositing a coin of this denomination. + fee_deposit: AmountString; + + // Fee charged by the exchange for refreshing a coin of this denomination. + fee_refresh: AmountString; + + // Fee charged by the exchange for refunding a coin of this denomination. + fee_refund: AmountString; + + // XOR of all the SHA-512 hash values of the denominations' public keys + // in this group. Note that for hashing, the binary format of the + // public keys is used, and not their base32 encoding. + hash: HashCodeString; +} + +export interface DenomCommon { + // Signature of TALER_DenominationKeyValidityPS. + master_sig: EddsaSignatureString; + + // When does the denomination key become valid? + stamp_start: TalerProtocolTimestamp; + + // When is it no longer possible to deposit coins + // of this denomination? + stamp_expire_withdraw: TalerProtocolTimestamp; + + // Timestamp indicating by when legal disputes relating to these coins must + // be settled, as the exchange will afterwards destroy its evidence relating to + // transactions involving this coin. + stamp_expire_legal: TalerProtocolTimestamp; + + stamp_expire_deposit: TalerProtocolTimestamp; + + // Set to 'true' if the exchange somehow "lost" + // the private key. The denomination was not + // necessarily revoked, but still cannot be used + // to withdraw coins at this time (theoretically, + // the private key could be recovered in the + // future; coins signed with the private key + // remain valid). + lost?: boolean; +} + +export type RsaPublicKeySring = string; +export type AgeMask = number; + +/** + * 32-byte value representing a point on Curve25519. + */ +export type Cs25519Point = string; + +export interface DenomGroupRsa extends DenomGroupCommon { + cipher: "RSA"; + + denoms: ({ + rsa_pub: RsaPublicKeySring; + } & DenomCommon)[]; +} + +export interface DenomGroupRsaAgeRestricted extends DenomGroupCommon { + cipher: "RSA+age_restricted"; + age_mask: AgeMask; + + denoms: ({ + rsa_pub: RsaPublicKeySring; + } & DenomCommon)[]; +} + +export interface DenomGroupCs extends DenomGroupCommon { + cipher: "CS"; + age_mask: AgeMask; + + denoms: ({ + cs_pub: Cs25519Point; + } & DenomCommon)[]; +} + +export interface DenomGroupCsAgeRestricted extends DenomGroupCommon { + cipher: "CS+age_restricted"; + age_mask: AgeMask; + + denoms: ({ + cs_pub: Cs25519Point; + } & DenomCommon)[]; } export interface GlobalFees { @@ -847,10 +948,10 @@ export interface AccountInfo { debit_restrictions?: any; } -export interface ExchangeWireJson { - accounts: AccountInfo[]; - fees: { [methodName: string]: WireFeesJson[] }; -} +/** + * @deprecated + */ +export interface ExchangeWireJson {} /** * Proposal returned from the contract URL. @@ -1404,10 +1505,13 @@ export const codecForGlobalFees = (): Codec => .property("master_sig", codecForString()) .build("GlobalFees"); +// FIXME: Validate properly! +export const codecForNgDenominations: Codec = codecForAny(); + export const codecForExchangeKeysJson = (): Codec => buildCodecForObject() - .property("denoms", codecForList(codecForDenomination())) .property("base_url", codecForString()) + .property("currency", codecForString()) .property("master_public_key", codecForString()) .property("auditors", codecForList(codecForAuditor())) .property("list_issue_date", codecForTimestamp) @@ -1416,6 +1520,9 @@ export const codecForExchangeKeysJson = (): Codec => .property("version", codecForString()) .property("reserve_closing_delay", codecForDuration) .property("global_fees", codecForList(codecForGlobalFees())) + .property("accounts", codecForList(codecForAccountInfo())) + .property("wire_fees", codecForMap(codecForList(codecForWireFeesJson()))) + .property("denominations", codecForList(codecForNgDenominations)) .build("ExchangeKeysJson"); export const codecForWireFeesJson = (): Codec => @@ -1436,12 +1543,6 @@ export const codecForAccountInfo = (): Codec => .property("debit_restrictions", codecForAny()) .build("AccountInfo"); -export const codecForExchangeWireJson = (): Codec => - buildCodecForObject() - .property("accounts", codecForList(codecForAccountInfo())) - .property("fees", codecForMap(codecForList(codecForWireFeesJson()))) - .build("ExchangeWireJson"); - export const codecForProposal = (): Codec => buildCodecForObject() .property("contract_terms", codecForAny()) diff --git a/packages/taler-wallet-core/src/bank-api-client.ts b/packages/taler-wallet-core/src/bank-api-client.ts index 8e351cb48..3174667f1 100644 --- a/packages/taler-wallet-core/src/bank-api-client.ts +++ b/packages/taler-wallet-core/src/bank-api-client.ts @@ -224,7 +224,7 @@ export namespace BankAccessApi { `accounts/${bankUser.username}`, bank.bankAccessApiBaseUrl, ); - const resp = await bank.http.get(url.href, { + const resp = await bank.http.fetch(url.href, { headers: { Authorization: makeBasicAuthHeader( bankUser.username, diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index 1d0d3a6e5..c5f8b6448 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -352,6 +352,7 @@ export interface DenomFees { export interface DenominationRecord { currency: string; + // FIXME: Use binary encoding of amount instead? amountVal: number; amountFrac: number; diff --git a/packages/taler-wallet-core/src/dbless.ts b/packages/taler-wallet-core/src/dbless.ts index 4dfdff3f7..5532345ae 100644 --- a/packages/taler-wallet-core/src/dbless.ts +++ b/packages/taler-wallet-core/src/dbless.ts @@ -137,7 +137,7 @@ export async function topupReserveWithDemobank( throw Error("no suggested exchange"); } const plainPaytoUris = - exchangeInfo.wire.accounts.map((x) => x.payto_uri) ?? []; + exchangeInfo.keys.accounts.map((x) => x.payto_uri) ?? []; if (plainPaytoUris.length <= 0) { throw new Error(); } @@ -338,7 +338,10 @@ export async function refreshCoin(req: { logger.info("requesting melt done"); - const meltHttpResp = await http.postJson(meltReqUrl.href, meltReqBody); + const meltHttpResp = await http.fetch(meltReqUrl.href, { + method: "POST", + body: meltReqBody, + }); const meltResponse = await readSuccessResponseJsonOrThrow( meltHttpResp, @@ -386,7 +389,7 @@ export async function createFakebankReserve(args: { exchangeInfo: ExchangeInfo; }): Promise { const { http, fakebankBaseUrl, amount, reservePub } = args; - const paytoUri = args.exchangeInfo.wire.accounts[0].payto_uri; + const paytoUri = args.exchangeInfo.keys.accounts[0].payto_uri; const pt = parsePaytoUri(paytoUri); if (!pt) { throw Error("failed to parse payto URI"); diff --git a/packages/taler-wallet-core/src/dev-experiments.ts b/packages/taler-wallet-core/src/dev-experiments.ts index 113e9bede..176ed09d9 100644 --- a/packages/taler-wallet-core/src/dev-experiments.ts +++ b/packages/taler-wallet-core/src/dev-experiments.ts @@ -70,7 +70,7 @@ export class DevExperimentHttpLib implements HttpRequestLibrary { opt?: HttpRequestOptions | undefined, ): Promise { logger.trace(`devexperiment httplib ${url}`); - return this.underlyingLib.get(url, opt); + return this.underlyingLib.fetch(url, opt); } postJson( diff --git a/packages/taler-wallet-core/src/operations/backup/index.ts b/packages/taler-wallet-core/src/operations/backup/index.ts index 236ef1e0f..e35765165 100644 --- a/packages/taler-wallet-core/src/operations/backup/index.ts +++ b/packages/taler-wallet-core/src/operations/backup/index.ts @@ -661,7 +661,7 @@ export async function addBackupProvider( } }); const termsUrl = new URL("config", canonUrl); - const resp = await ws.http.get(termsUrl.href); + const resp = await ws.http.fetch(termsUrl.href); const terms = await readSuccessResponseJsonOrThrow( resp, codecForSyncTermsOfServiceResponse(), diff --git a/packages/taler-wallet-core/src/operations/exchanges.ts b/packages/taler-wallet-core/src/operations/exchanges.ts index 8bf70fa27..c6b46e360 100644 --- a/packages/taler-wallet-core/src/operations/exchanges.ts +++ b/packages/taler-wallet-core/src/operations/exchanges.ts @@ -19,12 +19,14 @@ */ import { AbsoluteTime, + AccountInfo, Amounts, CancellationToken, canonicalizeBaseUrl, codecForExchangeKeysJson, - codecForExchangeWireJson, + DenomGroup, DenominationPubKey, + DenomKeyType, Duration, durationFromSpec, encodeCrock, @@ -51,6 +53,7 @@ import { URL, WireFee, WireFeeMap, + WireFeesJson, WireInfo, } from "@gnu-taler/taler-util"; import { @@ -84,43 +87,6 @@ import { const logger = new Logger("exchanges.ts"); -function denominationRecordFromKeys( - exchangeBaseUrl: string, - exchangeMasterPub: string, - listIssueDate: TalerProtocolTimestamp, - denomIn: ExchangeDenomination, -): DenominationRecord { - let denomPub: DenominationPubKey; - denomPub = denomIn.denom_pub; - const denomPubHash = encodeCrock(hashDenomPub(denomPub)); - const value = Amounts.parseOrThrow(denomIn.value); - const d: DenominationRecord = { - denomPub, - denomPubHash, - exchangeBaseUrl, - exchangeMasterPub, - fees: { - feeDeposit: Amounts.stringify(denomIn.fee_deposit), - feeRefresh: Amounts.stringify(denomIn.fee_refresh), - feeRefund: Amounts.stringify(denomIn.fee_refund), - feeWithdraw: Amounts.stringify(denomIn.fee_withdraw), - }, - isOffered: true, - isRevoked: false, - masterSig: denomIn.master_sig, - stampExpireDeposit: denomIn.stamp_expire_deposit, - stampExpireLegal: denomIn.stamp_expire_legal, - stampExpireWithdraw: denomIn.stamp_expire_withdraw, - stampStart: denomIn.stamp_start, - verificationStatus: DenominationVerificationStatus.Unverified, - amountFrac: value.fraction, - amountVal: value.value, - currency: value.currency, - listIssueDate, - }; - return d; -} - export function getExchangeRequestTimeout(): Duration { return Duration.fromSpec({ seconds: 5, @@ -145,7 +111,7 @@ export async function downloadExchangeWithTermsOfService( Accept: contentType, }; - const resp = await http.get(reqUrl.href, { + const resp = await http.fetch(reqUrl.href, { headers, timeout, }); @@ -241,7 +207,7 @@ export async function acceptExchangeTermsOfService( async function validateWireInfo( ws: InternalWalletState, versionCurrent: number, - wireInfo: ExchangeWireJson, + wireInfo: ExchangeKeysDownloadResult, masterPublicKey: string, ): Promise { for (const a of wireInfo.accounts) { @@ -267,9 +233,9 @@ async function validateWireInfo( } logger.trace("account validation done"); const feesForType: WireFeeMap = {}; - for (const wireMethod of Object.keys(wireInfo.fees)) { + for (const wireMethod of Object.keys(wireInfo.wireFees)) { const feeList: WireFee[] = []; - for (const x of wireInfo.fees[wireMethod]) { + for (const x of wireInfo.wireFees[wireMethod]) { const startStamp = x.start_date; const endStamp = x.end_date; const fee: WireFee = { @@ -343,7 +309,6 @@ async function validateGlobalFees( } export interface ExchangeInfo { - wire: ExchangeWireJson; keys: ExchangeKeysDownloadResult; } @@ -351,11 +316,6 @@ export async function downloadExchangeInfo( exchangeBaseUrl: string, http: HttpRequestLibrary, ): Promise { - const wireInfo = await downloadExchangeWireInfo( - exchangeBaseUrl, - http, - Duration.getForever(), - ); const keysInfo = await downloadExchangeKeysInfo( exchangeBaseUrl, http, @@ -363,33 +323,9 @@ export async function downloadExchangeInfo( ); return { keys: keysInfo, - wire: wireInfo, }; } -/** - * Fetch wire information for an exchange. - * - * @param exchangeBaseUrl Exchange base URL, assumed to be already normalized. - */ -async function downloadExchangeWireInfo( - exchangeBaseUrl: string, - http: HttpRequestLibrary, - timeout: Duration, -): Promise { - const reqUrl = new URL("wire", exchangeBaseUrl); - - const resp = await http.get(reqUrl.href, { - timeout, - }); - const wireInfo = await readSuccessResponseJsonOrThrow( - resp, - codecForExchangeWireJson(), - ); - - return wireInfo; -} - export async function provideExchangeRecordInTx( ws: InternalWalletState, tx: GetReadWriteAccess<{ @@ -434,6 +370,8 @@ interface ExchangeKeysDownloadResult { recoup: Recoup[]; listIssueDate: TalerProtocolTimestamp; globalFees: GlobalFees[]; + accounts: AccountInfo[]; + wireFees: { [methodName: string]: WireFeesJson[] }; } /** @@ -446,7 +384,7 @@ async function downloadExchangeKeysInfo( ): Promise { const keysUrl = new URL("keys", baseUrl); - const resp = await http.get(keysUrl.href, { + const resp = await http.fetch(keysUrl.href, { timeout, }); const exchangeKeysJsonUnchecked = await readSuccessResponseJsonOrThrow( @@ -454,7 +392,7 @@ async function downloadExchangeKeysInfo( codecForExchangeKeysJson(), ); - if (exchangeKeysJsonUnchecked.denoms.length === 0) { + if (exchangeKeysJsonUnchecked.denominations.length === 0) { throw TalerError.fromDetail( TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT, { @@ -481,23 +419,72 @@ async function downloadExchangeKeysInfo( ); } - const currency = Amounts.parseOrThrow( - exchangeKeysJsonUnchecked.denoms[0].value, - ).currency.toUpperCase(); + const currency = exchangeKeysJsonUnchecked.currency; + + const currentDenominations: DenominationRecord[] = []; + + for (const denomGroup of exchangeKeysJsonUnchecked.denominations) { + switch (denomGroup.cipher) { + case "RSA": + case "RSA+age_restricted": { + let ageMask = 0; + if (denomGroup.cipher === "RSA+age_restricted") { + ageMask = denomGroup.age_mask; + } + for (const denomIn of denomGroup.denoms) { + const denomPub: DenominationPubKey = { + age_mask: ageMask, + cipher: DenomKeyType.Rsa, + rsa_public_key: denomIn.rsa_pub, + }; + const denomPubHash = encodeCrock(hashDenomPub(denomPub)); + const value = Amounts.parseOrThrow(denomGroup.value); + const rec: DenominationRecord = { + denomPub, + denomPubHash, + exchangeBaseUrl: baseUrl, + exchangeMasterPub: exchangeKeysJsonUnchecked.master_public_key, + isOffered: true, + isRevoked: false, + amountFrac: value.fraction, + amountVal: value.value, + currency: value.currency, + stampExpireDeposit: denomIn.stamp_expire_deposit, + stampExpireLegal: denomIn.stamp_expire_legal, + stampExpireWithdraw: denomIn.stamp_expire_withdraw, + stampStart: denomIn.stamp_start, + verificationStatus: DenominationVerificationStatus.Unverified, + masterSig: denomIn.master_sig, + listIssueDate: exchangeKeysJsonUnchecked.list_issue_date, + fees: { + feeDeposit: Amounts.stringify(denomGroup.fee_deposit), + feeRefresh: Amounts.stringify(denomGroup.fee_refresh), + feeRefund: Amounts.stringify(denomGroup.fee_refund), + feeWithdraw: Amounts.stringify(denomGroup.fee_withdraw), + }, + }; + currentDenominations.push(rec); + } + break; + } + case "CS+age_restricted": + case "CS": + logger.warn("Clause-Schnorr denominations not supported"); + continue; + default: + logger.warn( + `denomination type ${(denomGroup as any).cipher} not supported`, + ); + continue; + } + } return { masterPublicKey: exchangeKeysJsonUnchecked.master_public_key, currency, baseUrl: exchangeKeysJsonUnchecked.base_url, auditors: exchangeKeysJsonUnchecked.auditors, - currentDenominations: exchangeKeysJsonUnchecked.denoms.map((d) => - denominationRecordFromKeys( - baseUrl, - exchangeKeysJsonUnchecked.master_public_key, - exchangeKeysJsonUnchecked.list_issue_date, - d, - ), - ), + currentDenominations, protocolVersion: exchangeKeysJsonUnchecked.version, signingKeys: exchangeKeysJsonUnchecked.signkeys, reserveClosingDelay: exchangeKeysJsonUnchecked.reserve_closing_delay, @@ -509,6 +496,8 @@ async function downloadExchangeKeysInfo( recoup: exchangeKeysJsonUnchecked.recoup ?? [], listIssueDate: exchangeKeysJsonUnchecked.list_issue_date, globalFees: exchangeKeysJsonUnchecked.global_fees, + accounts: exchangeKeysJsonUnchecked.accounts, + wireFees: exchangeKeysJsonUnchecked.wire_fees, }; } @@ -654,14 +643,7 @@ export async function updateExchangeFromUrlHandler( } } - logger.trace("updating exchange /wire info"); - const wireInfoDownload = await downloadExchangeWireInfo( - exchangeBaseUrl, - ws.http, - timeout, - ); - - logger.trace("validating exchange /wire info"); + logger.trace("validating exchange wire info"); const version = LibtoolVersion.parseVersion(keysInfo.protocolVersion); if (!version) { @@ -672,7 +654,7 @@ export async function updateExchangeFromUrlHandler( const wireInfo = await validateWireInfo( ws, version.current, - wireInfoDownload, + keysInfo, keysInfo.masterPublicKey, ); diff --git a/packages/taler-wallet-core/src/operations/merchants.ts b/packages/taler-wallet-core/src/operations/merchants.ts index c47ec4a0a..a148953f0 100644 --- a/packages/taler-wallet-core/src/operations/merchants.ts +++ b/packages/taler-wallet-core/src/operations/merchants.ts @@ -41,7 +41,7 @@ export async function getMerchantInfo( } const configUrl = new URL("config", canonBaseUrl); - const resp = await ws.http.get(configUrl.href); + const resp = await ws.http.fetch(configUrl.href); const configResp = await readSuccessResponseJsonOrThrow( resp, diff --git a/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts index eca3bc91b..8ba84585c 100644 --- a/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts +++ b/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts @@ -530,7 +530,7 @@ export async function preparePeerPullDebit( const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl); - const contractHttpResp = await ws.http.get(getContractUrl.href); + const contractHttpResp = await ws.http.fetch(getContractUrl.href); const contractResp = await readSuccessResponseJsonOrThrow( contractHttpResp, diff --git a/packages/taler-wallet-core/src/operations/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/operations/pay-peer-push-credit.ts index c552d63f0..47e9eaddd 100644 --- a/packages/taler-wallet-core/src/operations/pay-peer-push-credit.ts +++ b/packages/taler-wallet-core/src/operations/pay-peer-push-credit.ts @@ -165,7 +165,7 @@ export async function preparePeerPushCredit( const getPurseUrl = new URL(`purses/${pursePub}/deposit`, exchangeBaseUrl); - const purseHttpResp = await ws.http.get(getPurseUrl.href); + const purseHttpResp = await ws.http.fetch(getPurseUrl.href); const purseStatus = await readSuccessResponseJsonOrThrow( purseHttpResp, diff --git a/packages/taler-wallet-core/src/operations/recoup.ts b/packages/taler-wallet-core/src/operations/recoup.ts index abeca1119..6a18e5de6 100644 --- a/packages/taler-wallet-core/src/operations/recoup.ts +++ b/packages/taler-wallet-core/src/operations/recoup.ts @@ -358,7 +358,7 @@ export async function processRecoupGroup( ); logger.info(`querying reserve status for recoup via ${reserveUrl}`); - const resp = await ws.http.get(reserveUrl.href); + const resp = await ws.http.fetch(reserveUrl.href); const result = await readSuccessResponseJsonOrThrow( resp, diff --git a/packages/taler-wallet-core/src/operations/reward.ts b/packages/taler-wallet-core/src/operations/reward.ts index 47956f15f..69c888d7a 100644 --- a/packages/taler-wallet-core/src/operations/reward.ts +++ b/packages/taler-wallet-core/src/operations/reward.ts @@ -161,7 +161,7 @@ export async function prepareTip( res.merchantBaseUrl, ); logger.trace("checking tip status from", tipStatusUrl.href); - const merchantResp = await ws.http.get(tipStatusUrl.href); + const merchantResp = await ws.http.fetch(tipStatusUrl.href); const tipPickupStatus = await readSuccessResponseJsonOrThrow( merchantResp, codecForTipPickupGetResponse(), diff --git a/packages/taler-wallet-core/src/operations/testing.ts b/packages/taler-wallet-core/src/operations/testing.ts index 3090549d5..aff92622a 100644 --- a/packages/taler-wallet-core/src/operations/testing.ts +++ b/packages/taler-wallet-core/src/operations/testing.ts @@ -293,7 +293,7 @@ async function checkPayment( ): Promise { const reqUrl = new URL(`private/orders/${orderId}`, merchantBackend.baseUrl); reqUrl.searchParams.set("order_id", orderId); - const resp = await http.get(reqUrl.href, { + const resp = await http.fetch(reqUrl.href, { headers: getMerchantAuthHeader(merchantBackend), }); return readSuccessResponseJsonOrThrow(resp, codecForCheckPaymentResponse()); diff --git a/packages/taler-wallet-core/src/operations/withdraw.ts b/packages/taler-wallet-core/src/operations/withdraw.ts index 673129928..040d191e1 100644 --- a/packages/taler-wallet-core/src/operations/withdraw.ts +++ b/packages/taler-wallet-core/src/operations/withdraw.ts @@ -554,7 +554,7 @@ export async function getBankWithdrawalInfo( const configReqUrl = new URL("config", uriResult.bankIntegrationApiBaseUrl); - const configResp = await http.get(configReqUrl.href); + const configResp = await http.fetch(configReqUrl.href); const config = await readSuccessResponseJsonOrThrow( configResp, codecForTalerConfigResponse(), @@ -582,7 +582,7 @@ export async function getBankWithdrawalInfo( logger.info(`bank withdrawal status URL: ${reqUrl.href}}`); - const resp = await http.get(reqUrl.href); + const resp = await http.fetch(reqUrl.href); const status = await readSuccessResponseJsonOrThrow( resp, codecForWithdrawOperationStatusResponse(), @@ -2098,7 +2098,7 @@ async function processReserveBankStatus( const bankStatusUrl = getBankStatusUrl(bankInfo.talerWithdrawUri); - const statusResp = await ws.http.get(bankStatusUrl, { + const statusResp = await ws.http.fetch(bankStatusUrl, { timeout: getReserveRequestTimeout(withdrawalGroup), }); const status = await readSuccessResponseJsonOrThrow( -- cgit v1.2.3 From a0d92d086f9580d540688f0225cb679d09b01fe5 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Tue, 29 Aug 2023 13:53:53 +0200 Subject: taler-wallet-core: bump exchange version number --- packages/taler-wallet-core/src/versions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/taler-wallet-core') diff --git a/packages/taler-wallet-core/src/versions.ts b/packages/taler-wallet-core/src/versions.ts index f0f747e22..8b9177bc3 100644 --- a/packages/taler-wallet-core/src/versions.ts +++ b/packages/taler-wallet-core/src/versions.ts @@ -19,7 +19,7 @@ * * Uses libtool's current:revision:age versioning. */ -export const WALLET_EXCHANGE_PROTOCOL_VERSION = "15:0:2"; +export const WALLET_EXCHANGE_PROTOCOL_VERSION = "17:0:0"; /** * Protocol version spoken with the merchant. -- cgit v1.2.3 From 5aa883b5d1f83f030741fc0ed53c26f965384001 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Tue, 29 Aug 2023 13:55:14 +0200 Subject: repo: make incremental builds faster, don't use TypeScript build mode (build mode should not be necessary since pnpm handles dependencies inside the repo) --- packages/anastasis-cli/package.json | 2 +- packages/idb-bridge/package.json | 2 +- packages/pogen/package.json | 2 +- packages/taler-harness/package.json | 4 ++-- packages/taler-util/package.json | 2 +- packages/taler-wallet-cli/package.json | 4 ++-- packages/taler-wallet-core/package.json | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) (limited to 'packages/taler-wallet-core') diff --git a/packages/anastasis-cli/package.json b/packages/anastasis-cli/package.json index 1821cd696..2bbf32c3b 100644 --- a/packages/anastasis-cli/package.json +++ b/packages/anastasis-cli/package.json @@ -16,7 +16,7 @@ }, "type": "module", "scripts": { - "compile": "tsc --build && ./build-node.mjs", + "compile": "tsc && ./build-node.mjs", "test": "tsc", "clean": "rimraf lib dist tsconfig.tsbuildinfo", "pretty": "prettier --write src" diff --git a/packages/idb-bridge/package.json b/packages/idb-bridge/package.json index 2677c302f..1c9c46b02 100644 --- a/packages/idb-bridge/package.json +++ b/packages/idb-bridge/package.json @@ -11,7 +11,7 @@ "private": false, "scripts": { "test": "tsc && ava", - "compile": "tsc --build", + "compile": "tsc", "clean": "rimraf dist lib tsconfig.tsbuildinfo", "pretty": "prettier --write src" }, diff --git a/packages/pogen/package.json b/packages/pogen/package.json index c5252cd62..ae1dfac3b 100644 --- a/packages/pogen/package.json +++ b/packages/pogen/package.json @@ -7,7 +7,7 @@ "author": "Florian Dold", "license": "GPL-2.0+", "scripts": { - "compile": "tsc --build" + "compile": "tsc" }, "devDependencies": { "po2json": "^0.4.5", diff --git a/packages/taler-harness/package.json b/packages/taler-harness/package.json index 435ff8a59..8916e5e9e 100644 --- a/packages/taler-harness/package.json +++ b/packages/taler-harness/package.json @@ -16,7 +16,7 @@ }, "type": "module", "scripts": { - "compile": "tsc --build && ./build.mjs", + "compile": "tsc && ./build.mjs", "check": "tsc", "test": "tsc", "clean": "rimraf lib dist tsconfig.tsbuildinfo", @@ -42,4 +42,4 @@ "@gnu-taler/taler-wallet-core": "workspace:*", "tslib": "^2.5.3" } -} \ No newline at end of file +} diff --git a/packages/taler-util/package.json b/packages/taler-util/package.json index 504b8259f..6ac9a2689 100644 --- a/packages/taler-util/package.json +++ b/packages/taler-util/package.json @@ -57,7 +57,7 @@ } }, "scripts": { - "compile": "tsc --build", + "compile": "tsc", "test": "tsc && ava", "clean": "rimraf dist lib tsconfig.tsbuildinfo", "pretty": "prettier --write src" diff --git a/packages/taler-wallet-cli/package.json b/packages/taler-wallet-cli/package.json index 36201759e..06df1da76 100644 --- a/packages/taler-wallet-cli/package.json +++ b/packages/taler-wallet-cli/package.json @@ -16,7 +16,7 @@ }, "type": "module", "scripts": { - "compile": "tsc --build && ./build-node.mjs", + "compile": "tsc && ./build-node.mjs", "test": "tsc", "clean": "rimraf lib dist tsconfig.tsbuildinfo", "pretty": "prettier --write src" @@ -41,4 +41,4 @@ "@gnu-taler/taler-wallet-core": "workspace:*", "tslib": "^2.5.3" } -} \ No newline at end of file +} diff --git a/packages/taler-wallet-core/package.json b/packages/taler-wallet-core/package.json index c471770de..9cd6f6466 100644 --- a/packages/taler-wallet-core/package.json +++ b/packages/taler-wallet-core/package.json @@ -12,7 +12,7 @@ "author": "Florian Dold", "license": "GPL-3.0", "scripts": { - "compile": "tsc --build", + "compile": "tsc", "pretty": "prettier --write src", "test": "tsc && ava", "coverage": "tsc && c8 --src src --all ava", -- cgit v1.2.3 From 5852b5cf2e91d23a97e757c557226051741f1f3a Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Tue, 29 Aug 2023 14:22:43 +0200 Subject: wallet-core: update deposit signature --- packages/taler-wallet-core/src/crypto/cryptoImplementation.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) (limited to 'packages/taler-wallet-core') diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts index 01d2677dc..c1a761fb6 100644 --- a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts +++ b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts @@ -723,7 +723,7 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { maybeAcp = await AgeRestriction.restrictionCommitSeeded( denomPub.age_mask, age, - stringToBytes(req.secretSeed) + stringToBytes(req.secretSeed), ); maybeAgeCommitmentHash = AgeRestriction.hashCommitment( maybeAcp.commitment, @@ -1127,6 +1127,8 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { // All zeros. hAgeCommitment = new Uint8Array(32); } + // FIXME: Actually allow passing user data here! + const walletDataHash = new Uint8Array(64); let d: Uint8Array; if (depositInfo.denomKeyType === DenomKeyType.Rsa) { d = buildSigPS(TalerSignaturePurpose.WALLET_COIN_DEPOSIT) @@ -1140,6 +1142,7 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { .put(amountToBuffer(depositInfo.spendAmount)) .put(amountToBuffer(depositInfo.feeDeposit)) .put(decodeCrock(depositInfo.merchantPub)) + .put(walletDataHash) .build(); } else { throw Error("unsupported exchange protocol version"); -- cgit v1.2.3 From a386de8a9c1aa3fff76b4cb37fb3287213981387 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Tue, 29 Aug 2023 18:33:51 +0200 Subject: wallet-core: split coin selection and instructed amount conversion --- .../src/operations/pay-peer-common.ts | 265 +---- .../src/operations/pay-peer-pull-debit.ts | 3 +- .../src/operations/pay-peer-push-debit.ts | 3 +- .../src/util/coinSelection.test.ts | 742 -------------- .../taler-wallet-core/src/util/coinSelection.ts | 1012 +++++--------------- .../src/util/instructedAmountConversion.test.ts | 763 +++++++++++++++ .../src/util/instructedAmountConversion.ts | 849 ++++++++++++++++ packages/taler-wallet-core/src/wallet.ts | 14 +- 8 files changed, 1859 insertions(+), 1792 deletions(-) create mode 100644 packages/taler-wallet-core/src/util/instructedAmountConversion.test.ts create mode 100644 packages/taler-wallet-core/src/util/instructedAmountConversion.ts (limited to 'packages/taler-wallet-core') diff --git a/packages/taler-wallet-core/src/operations/pay-peer-common.ts b/packages/taler-wallet-core/src/operations/pay-peer-common.ts index 4fdfecb4d..49f255eb9 100644 --- a/packages/taler-wallet-core/src/operations/pay-peer-common.ts +++ b/packages/taler-wallet-core/src/operations/pay-peer-common.ts @@ -43,8 +43,6 @@ import { import { SpendCoinDetails } from "../crypto/cryptoImplementation.js"; import { DenominationRecord, - KycPendingInfo, - KycUserType, PeerPushPaymentCoinSelection, ReserveRecord, } from "../db.js"; @@ -52,68 +50,13 @@ import { InternalWalletState } from "../internal-wallet-state.js"; import { checkDbInvariant } from "../util/invariants.js"; import { getPeerPaymentBalanceDetailsInTx } from "./balance.js"; import { getTotalRefreshCost } from "./refresh.js"; +import type { PeerCoinInfo, PeerCoinSelectionRequest, SelectPeerCoinsResult, SelectedPeerCoin } from "../util/coinSelection.js"; const logger = new Logger("operations/peer-to-peer.ts"); -interface SelectedPeerCoin { - coinPub: string; - coinPriv: string; - contribution: AmountString; - denomPubHash: string; - denomSig: UnblindedSignature; - ageCommitmentProof: AgeCommitmentProof | undefined; -} - -interface PeerCoinSelectionDetails { - exchangeBaseUrl: string; - - /** - * Info of Coins that were selected. - */ - coins: SelectedPeerCoin[]; - - /** - * How much of the deposit fees is the customer paying? - */ - depositFees: AmountJson; -} - -/** - * Information about a selected coin for peer to peer payments. - */ -interface CoinInfo { - /** - * Public key of the coin. - */ - coinPub: string; - - coinPriv: string; - - /** - * Deposit fee for the coin. - */ - feeDeposit: AmountJson; - - value: AmountJson; - - denomPubHash: string; - - denomSig: UnblindedSignature; - - maxAge: number; - - ageCommitmentProof?: AgeCommitmentProof; -} - -export type SelectPeerCoinsResult = - | { type: "success"; result: PeerCoinSelectionDetails } - | { - type: "failure"; - insufficientBalanceDetails: PayPeerInsufficientBalanceDetails; - }; - /** * Get information about the coin selected for signatures + * * @param ws * @param csel * @returns @@ -153,211 +96,7 @@ export async function queryCoinInfosForSelection( return infos; } -export interface PeerCoinRepair { - exchangeBaseUrl: string; - coinPubs: CoinPublicKeyString[]; - contribs: AmountJson[]; -} - -export interface PeerCoinSelectionRequest { - instructedAmount: AmountJson; - /** - * Instruct the coin selection to repair this coin - * selection instead of selecting completely new coins. - */ - repair?: PeerCoinRepair; -} - -export async function selectPeerCoins( - ws: InternalWalletState, - req: PeerCoinSelectionRequest, -): Promise { - const instructedAmount = req.instructedAmount; - if (Amounts.isZero(instructedAmount)) { - // Other parts of the code assume that we have at least - // one coin to spend. - throw new Error("amount of zero not allowed"); - } - return await ws.db - .mktx((x) => [ - x.exchanges, - x.contractTerms, - x.coins, - x.coinAvailability, - x.denominations, - x.refreshGroups, - x.peerPushPaymentInitiations, - ]) - .runReadWrite(async (tx) => { - const exchanges = await tx.exchanges.iter().toArray(); - const exchangeFeeGap: { [url: string]: AmountJson } = {}; - const currency = Amounts.currencyOf(instructedAmount); - for (const exch of exchanges) { - if (exch.detailsPointer?.currency !== currency) { - continue; - } - // FIXME: Can't we do this faster by using coinAvailability? - const coins = ( - await tx.coins.indexes.byBaseUrl.getAll(exch.baseUrl) - ).filter((x) => x.status === CoinStatus.Fresh); - const coinInfos: CoinInfo[] = []; - for (const coin of coins) { - const denom = await ws.getDenomInfo( - ws, - tx, - coin.exchangeBaseUrl, - coin.denomPubHash, - ); - if (!denom) { - throw Error("denom not found"); - } - coinInfos.push({ - coinPub: coin.coinPub, - feeDeposit: Amounts.parseOrThrow(denom.feeDeposit), - value: Amounts.parseOrThrow(denom.value), - denomPubHash: denom.denomPubHash, - coinPriv: coin.coinPriv, - denomSig: coin.denomSig, - maxAge: coin.maxAge, - ageCommitmentProof: coin.ageCommitmentProof, - }); - } - if (coinInfos.length === 0) { - continue; - } - coinInfos.sort( - (o1, o2) => - -Amounts.cmp(o1.value, o2.value) || - strcmp(o1.denomPubHash, o2.denomPubHash), - ); - let amountAcc = Amounts.zeroOfCurrency(currency); - let depositFeesAcc = Amounts.zeroOfCurrency(currency); - const resCoins: { - coinPub: string; - coinPriv: string; - contribution: AmountString; - denomPubHash: string; - denomSig: UnblindedSignature; - ageCommitmentProof: AgeCommitmentProof | undefined; - }[] = []; - let lastDepositFee = Amounts.zeroOfCurrency(currency); - - if (req.repair) { - for (let i = 0; i < req.repair.coinPubs.length; i++) { - const contrib = req.repair.contribs[i]; - const coin = await tx.coins.get(req.repair.coinPubs[i]); - if (!coin) { - throw Error("repair not possible, coin not found"); - } - const denom = await ws.getDenomInfo( - ws, - tx, - coin.exchangeBaseUrl, - coin.denomPubHash, - ); - checkDbInvariant(!!denom); - resCoins.push({ - coinPriv: coin.coinPriv, - coinPub: coin.coinPub, - contribution: Amounts.stringify(contrib), - denomPubHash: coin.denomPubHash, - denomSig: coin.denomSig, - ageCommitmentProof: coin.ageCommitmentProof, - }); - const depositFee = Amounts.parseOrThrow(denom.feeDeposit); - lastDepositFee = depositFee; - amountAcc = Amounts.add( - amountAcc, - Amounts.sub(contrib, depositFee).amount, - ).amount; - depositFeesAcc = Amounts.add(depositFeesAcc, depositFee).amount; - } - } - - for (const coin of coinInfos) { - if (Amounts.cmp(amountAcc, instructedAmount) >= 0) { - break; - } - const gap = Amounts.add( - coin.feeDeposit, - Amounts.sub(instructedAmount, amountAcc).amount, - ).amount; - const contrib = Amounts.min(gap, coin.value); - amountAcc = Amounts.add( - amountAcc, - Amounts.sub(contrib, coin.feeDeposit).amount, - ).amount; - depositFeesAcc = Amounts.add(depositFeesAcc, coin.feeDeposit).amount; - resCoins.push({ - coinPriv: coin.coinPriv, - coinPub: coin.coinPub, - contribution: Amounts.stringify(contrib), - denomPubHash: coin.denomPubHash, - denomSig: coin.denomSig, - ageCommitmentProof: coin.ageCommitmentProof, - }); - lastDepositFee = coin.feeDeposit; - } - if (Amounts.cmp(amountAcc, instructedAmount) >= 0) { - const res: PeerCoinSelectionDetails = { - exchangeBaseUrl: exch.baseUrl, - coins: resCoins, - depositFees: depositFeesAcc, - }; - return { type: "success", result: res }; - } - const diff = Amounts.sub(instructedAmount, amountAcc).amount; - exchangeFeeGap[exch.baseUrl] = Amounts.add(lastDepositFee, diff).amount; - - continue; - } - - // We were unable to select coins. - // Now we need to produce error details. - - const infoGeneral = await getPeerPaymentBalanceDetailsInTx(ws, tx, { - currency, - }); - - const perExchange: PayPeerInsufficientBalanceDetails["perExchange"] = {}; - - let maxFeeGapEstimate = Amounts.zeroOfCurrency(currency); - - for (const exch of exchanges) { - if (exch.detailsPointer?.currency !== currency) { - continue; - } - const infoExchange = await getPeerPaymentBalanceDetailsInTx(ws, tx, { - currency, - restrictExchangeTo: exch.baseUrl, - }); - let gap = - exchangeFeeGap[exch.baseUrl] ?? Amounts.zeroOfCurrency(currency); - if (Amounts.cmp(infoExchange.balanceMaterial, instructedAmount) < 0) { - // Show fee gap only if we should've been able to pay with the material amount - gap = Amounts.zeroOfCurrency(currency); - } - perExchange[exch.baseUrl] = { - balanceAvailable: Amounts.stringify(infoExchange.balanceAvailable), - balanceMaterial: Amounts.stringify(infoExchange.balanceMaterial), - feeGapEstimate: Amounts.stringify(gap), - }; - - maxFeeGapEstimate = Amounts.max(maxFeeGapEstimate, gap); - } - - const errDetails: PayPeerInsufficientBalanceDetails = { - amountRequested: Amounts.stringify(instructedAmount), - balanceAvailable: Amounts.stringify(infoGeneral.balanceAvailable), - balanceMaterial: Amounts.stringify(infoGeneral.balanceMaterial), - feeGapEstimate: Amounts.stringify(maxFeeGapEstimate), - perExchange, - }; - - return { type: "failure", insufficientBalanceDetails: errDetails }; - }); -} export async function getTotalPeerPaymentCost( ws: InternalWalletState, diff --git a/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts index 8ba84585c..0de91bf97 100644 --- a/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts +++ b/packages/taler-wallet-core/src/operations/pay-peer-pull-debit.ts @@ -68,11 +68,9 @@ import { spendCoins, } from "./common.js"; import { - PeerCoinRepair, codecForExchangePurseStatus, getTotalPeerPaymentCost, queryCoinInfosForSelection, - selectPeerCoins, } from "./pay-peer-common.js"; import { constructTransactionIdentifier, @@ -80,6 +78,7 @@ import { parseTransactionIdentifier, stopLongpolling, } from "./transactions.js"; +import { PeerCoinRepair, selectPeerCoins } from "../util/coinSelection.js"; const logger = new Logger("pay-peer-pull-debit.ts"); diff --git a/packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts index c853bc0ef..2349e5c4a 100644 --- a/packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts +++ b/packages/taler-wallet-core/src/operations/pay-peer-push-debit.ts @@ -68,17 +68,16 @@ import { spendCoins, } from "./common.js"; import { - PeerCoinRepair, codecForExchangePurseStatus, getTotalPeerPaymentCost, queryCoinInfosForSelection, - selectPeerCoins, } from "./pay-peer-common.js"; import { constructTransactionIdentifier, notifyTransition, stopLongpolling, } from "./transactions.js"; +import { PeerCoinRepair, selectPeerCoins } from "../util/coinSelection.js"; const logger = new Logger("pay-peer-push-debit.ts"); diff --git a/packages/taler-wallet-core/src/util/coinSelection.test.ts b/packages/taler-wallet-core/src/util/coinSelection.test.ts index fddd217ea..b907eb160 100644 --- a/packages/taler-wallet-core/src/util/coinSelection.test.ts +++ b/packages/taler-wallet-core/src/util/coinSelection.test.ts @@ -22,746 +22,4 @@ import { TransactionAmountMode, } from "@gnu-taler/taler-util"; import test, { ExecutionContext } from "ava"; -import { - CoinInfo, - convertDepositAmountForAvailableCoins, - convertWithdrawalAmountFromAvailableCoins, - getMaxDepositAmountForAvailableCoins, -} from "./coinSelection.js"; - -function makeCurrencyHelper(currency: string) { - return (sx: TemplateStringsArray, ...vx: any[]) => { - const s = String.raw({ raw: sx }, ...vx); - return Amounts.parseOrThrow(`${currency}:${s}`); - }; -} - -const kudos = makeCurrencyHelper("kudos"); - -function defaultFeeConfig(value: AmountJson, totalAvailable: number): CoinInfo { - return { - id: Amounts.stringify(value), - denomDeposit: kudos`0.01`, - denomRefresh: kudos`0.01`, - denomWithdraw: kudos`0.01`, - exchangeBaseUrl: "1", - duration: Duration.getForever(), - exchangePurse: undefined, - exchangeWire: undefined, - maxAge: AgeRestriction.AGE_UNRESTRICTED, - totalAvailable, - value, - }; -} -type Coin = [AmountJson, number]; - -/** - * Making a deposit with effective amount - * - */ - -test("deposit effective 2", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`2`, - TransactionAmountMode.Effective, - ); - t.is(Amounts.stringifyValue(result.effective), "2"); - t.is(Amounts.stringifyValue(result.raw), "1.99"); -}); - -test("deposit effective 10", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`10`, - TransactionAmountMode.Effective, - ); - t.is(Amounts.stringifyValue(result.effective), "10"); - t.is(Amounts.stringifyValue(result.raw), "9.98"); -}); - -test("deposit effective 24", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`24`, - TransactionAmountMode.Effective, - ); - t.is(Amounts.stringifyValue(result.effective), "24"); - t.is(Amounts.stringifyValue(result.raw), "23.94"); -}); - -test("deposit effective 40", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`40`, - TransactionAmountMode.Effective, - ); - t.is(Amounts.stringifyValue(result.effective), "35"); - t.is(Amounts.stringifyValue(result.raw), "34.9"); -}); - -test("deposit with wire fee effective 2", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: { - one: { - wireFee: kudos`0.1`, - purseFee: kudos`0.00`, - creditDeadline: AbsoluteTime.never(), - debitDeadline: AbsoluteTime.never(), - }, - }, - }, - kudos`2`, - TransactionAmountMode.Effective, - ); - t.is(Amounts.stringifyValue(result.effective), "2"); - t.is(Amounts.stringifyValue(result.raw), "1.89"); -}); - -/** - * Making a deposit with raw amount, using the result from effective - * - */ - -test("deposit raw 1.99 (effective 2)", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`1.99`, - TransactionAmountMode.Raw, - ); - t.is(Amounts.stringifyValue(result.effective), "2"); - t.is(Amounts.stringifyValue(result.raw), "1.99"); -}); - -test("deposit raw 9.98 (effective 10)", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`9.98`, - TransactionAmountMode.Raw, - ); - t.is(Amounts.stringifyValue(result.effective), "10"); - t.is(Amounts.stringifyValue(result.raw), "9.98"); -}); - -test("deposit raw 23.94 (effective 24)", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`23.94`, - TransactionAmountMode.Raw, - ); - t.is(Amounts.stringifyValue(result.effective), "24"); - t.is(Amounts.stringifyValue(result.raw), "23.94"); -}); - -test("deposit raw 34.9 (effective 40)", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`34.9`, - TransactionAmountMode.Raw, - ); - t.is(Amounts.stringifyValue(result.effective), "35"); - t.is(Amounts.stringifyValue(result.raw), "34.9"); -}); - -test("deposit with wire fee raw 2", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: { - one: { - wireFee: kudos`0.1`, - purseFee: kudos`0.00`, - creditDeadline: AbsoluteTime.never(), - debitDeadline: AbsoluteTime.never(), - }, - }, - }, - kudos`2`, - TransactionAmountMode.Effective, - ); - t.is(Amounts.stringifyValue(result.effective), "2"); - t.is(Amounts.stringifyValue(result.raw), "1.89"); -}); - -/** - * Calculating the max amount possible to deposit - * - */ - -test("deposit max 35", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = getMaxDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: { - "2": { - wireFee: kudos`0.00`, - purseFee: kudos`0.00`, - creditDeadline: AbsoluteTime.never(), - debitDeadline: AbsoluteTime.never(), - }, - }, - }, - "KUDOS", - ); - t.is(Amounts.stringifyValue(result.raw), "34.9"); - t.is(Amounts.stringifyValue(result.effective), "35"); -}); - -test("deposit max 35 with wirefee", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = getMaxDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: { - "2": { - wireFee: kudos`1`, - purseFee: kudos`0.00`, - creditDeadline: AbsoluteTime.never(), - debitDeadline: AbsoluteTime.never(), - }, - }, - }, - "KUDOS", - ); - t.is(Amounts.stringifyValue(result.raw), "33.9"); - t.is(Amounts.stringifyValue(result.effective), "35"); -}); - -test("deposit max repeated denom", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 1], - [kudos`2`, 1], - [kudos`5`, 1], - ]; - const result = getMaxDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: { - "2": { - wireFee: kudos`0.00`, - purseFee: kudos`0.00`, - creditDeadline: AbsoluteTime.never(), - debitDeadline: AbsoluteTime.never(), - }, - }, - }, - "KUDOS", - ); - t.is(Amounts.stringifyValue(result.raw), "8.97"); - t.is(Amounts.stringifyValue(result.effective), "9"); -}); - -/** - * Making a withdrawal with effective amount - * - */ - -test("withdraw effective 2", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertWithdrawalAmountFromAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`2`, - TransactionAmountMode.Effective, - ); - t.is(Amounts.stringifyValue(result.effective), "2"); - t.is(Amounts.stringifyValue(result.raw), "2.01"); -}); - -test("withdraw effective 10", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertWithdrawalAmountFromAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`10`, - TransactionAmountMode.Effective, - ); - t.is(Amounts.stringifyValue(result.effective), "10"); - t.is(Amounts.stringifyValue(result.raw), "10.02"); -}); - -test("withdraw effective 24", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertWithdrawalAmountFromAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`24`, - TransactionAmountMode.Effective, - ); - t.is(Amounts.stringifyValue(result.effective), "24"); - t.is(Amounts.stringifyValue(result.raw), "24.06"); -}); - -test("withdraw effective 40", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertWithdrawalAmountFromAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`40`, - TransactionAmountMode.Effective, - ); - t.is(Amounts.stringifyValue(result.effective), "40"); - t.is(Amounts.stringifyValue(result.raw), "40.08"); -}); - -/** - * Making a deposit with raw amount, using the result from effective - * - */ - -test("withdraw raw 2.01 (effective 2)", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertWithdrawalAmountFromAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`2.01`, - TransactionAmountMode.Raw, - ); - t.is(Amounts.stringifyValue(result.effective), "2"); - t.is(Amounts.stringifyValue(result.raw), "2.01"); -}); - -test("withdraw raw 10.02 (effective 10)", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertWithdrawalAmountFromAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`10.02`, - TransactionAmountMode.Raw, - ); - t.is(Amounts.stringifyValue(result.effective), "10"); - t.is(Amounts.stringifyValue(result.raw), "10.02"); -}); - -test("withdraw raw 24.06 (effective 24)", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertWithdrawalAmountFromAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`24.06`, - TransactionAmountMode.Raw, - ); - t.is(Amounts.stringifyValue(result.effective), "24"); - t.is(Amounts.stringifyValue(result.raw), "24.06"); -}); - -test("withdraw raw 40.08 (effective 40)", (t) => { - const coinList: Coin[] = [ - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertWithdrawalAmountFromAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`40.08`, - TransactionAmountMode.Raw, - ); - t.is(Amounts.stringifyValue(result.effective), "40"); - t.is(Amounts.stringifyValue(result.raw), "40.08"); -}); - -test("withdraw raw 25", (t) => { - const coinList: Coin[] = [ - [kudos`0.1`, 0], - [kudos`1`, 0], - [kudos`2`, 0], - [kudos`5`, 0], - ]; - const result = convertWithdrawalAmountFromAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`25`, - TransactionAmountMode.Raw, - ); - t.is(Amounts.stringifyValue(result.effective), "24.8"); - t.is(Amounts.stringifyValue(result.raw), "24.94"); -}); - -test("withdraw effective 24.8 (raw 25)", (t) => { - const coinList: Coin[] = [ - [kudos`0.1`, 0], - [kudos`1`, 0], - [kudos`2`, 0], - [kudos`5`, 0], - ]; - const result = convertWithdrawalAmountFromAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`24.8`, - TransactionAmountMode.Effective, - ); - t.is(Amounts.stringifyValue(result.effective), "24.8"); - t.is(Amounts.stringifyValue(result.raw), "24.94"); -}); - -/** - * Making a deposit with refresh - * - */ - -test("deposit with refresh: effective 3", (t) => { - const coinList: Coin[] = [ - [kudos`0.1`, 0], - [kudos`1`, 0], - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`3`, - TransactionAmountMode.Effective, - ); - t.is(Amounts.stringifyValue(result.effective), "3.1"); - t.is(Amounts.stringifyValue(result.raw), "2.98"); - expectDefined(t, result.refresh); - //FEES - //deposit 2 x 0.01 - //refresh 1 x 0.01 - //withdraw 9 x 0.01 - //----------------- - //op 0.12 - - //coins sent 2 x 2.0 - //coins recv 9 x 0.1 - //------------------- - //effective 3.10 - //raw 2.98 - t.is(Amounts.stringifyValue(result.refresh.selected.id), "2"); - t.deepEqual(asCoinList(result.refresh.coins), [[kudos`0.1`, 9]]); -}); - -test("deposit with refresh: raw 2.98 (effective 3)", (t) => { - const coinList: Coin[] = [ - [kudos`0.1`, 0], - [kudos`1`, 0], - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`2.98`, - TransactionAmountMode.Raw, - ); - t.is(Amounts.stringifyValue(result.effective), "3.2"); - t.is(Amounts.stringifyValue(result.raw), "3.09"); - expectDefined(t, result.refresh); - //FEES - //deposit 1 x 0.01 - //refresh 1 x 0.01 - //withdraw 8 x 0.01 - //----------------- - //op 0.10 - - //coins sent 1 x 2.0 - //coins recv 8 x 0.1 - //------------------- - //effective 3.20 - //raw 3.09 - t.is(Amounts.stringifyValue(result.refresh.selected.id), "2"); - t.deepEqual(asCoinList(result.refresh.coins), [[kudos`0.1`, 8]]); -}); - -test("deposit with refresh: effective 3.2 (raw 2.98)", (t) => { - const coinList: Coin[] = [ - [kudos`0.1`, 0], - [kudos`1`, 0], - [kudos`2`, 5], - [kudos`5`, 5], - ]; - const result = convertDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`3.2`, - TransactionAmountMode.Effective, - ); - t.is(Amounts.stringifyValue(result.effective), "3.3"); - t.is(Amounts.stringifyValue(result.raw), "3.2"); - expectDefined(t, result.refresh); - //FEES - //deposit 2 x 0.01 - //refresh 1 x 0.01 - //withdraw 7 x 0.01 - //----------------- - //op 0.10 - - //coins sent 2 x 2.0 - //coins recv 7 x 0.1 - //------------------- - //effective 3.30 - //raw 3.20 - t.is(Amounts.stringifyValue(result.refresh.selected.id), "2"); - t.deepEqual(asCoinList(result.refresh.coins), [[kudos`0.1`, 7]]); -}); - -function expectDefined( - t: ExecutionContext, - v: T | undefined, -): asserts v is T { - t.assert(v !== undefined); -} - -function asCoinList(v: { info: CoinInfo; size: number }[]): any { - return v.map((c) => { - return [c.info.value, c.size]; - }); -} - -/** - * regression tests - */ - -test("demo: withdraw raw 25", (t) => { - const coinList: Coin[] = [ - [kudos`0.1`, 0], - [kudos`1`, 0], - [kudos`2`, 0], - [kudos`5`, 0], - [kudos`10`, 0], - ]; - const result = convertWithdrawalAmountFromAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`25`, - TransactionAmountMode.Raw, - ); - t.is(Amounts.stringifyValue(result.effective), "24.8"); - t.is(Amounts.stringifyValue(result.raw), "24.92"); - // coins received - // 8 x 0.1 - // 2 x 0.2 - // 2 x 10.0 - // total effective 24.8 - // fee 12 x 0.01 = 0.12 - // total raw 24.92 - // left in reserve 25 - 24.92 == 0.08 - - //current wallet impl: hides the left in reserve fee - //shows fee = 0.2 -}); - -test("demo: deposit max after withdraw raw 25", (t) => { - const coinList: Coin[] = [ - [kudos`0.1`, 8], - [kudos`1`, 0], - [kudos`2`, 2], - [kudos`5`, 0], - [kudos`10`, 2], - ]; - const result = getMaxDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: { - one: { - wireFee: kudos`0.01`, - purseFee: kudos`0.00`, - creditDeadline: AbsoluteTime.never(), - debitDeadline: AbsoluteTime.never(), - }, - }, - }, - "KUDOS", - ); - t.is(Amounts.stringifyValue(result.effective), "24.8"); - t.is(Amounts.stringifyValue(result.raw), "24.67"); - - // 8 x 0.1 - // 2 x 0.2 - // 2 x 10.0 - // total effective 24.8 - // deposit fee 12 x 0.01 = 0.12 - // wire fee 0.01 - // total raw: 24.8 - 0.13 = 24.67 - - // current wallet impl fee 0.14 -}); - -test("demo: withdraw raw 13", (t) => { - const coinList: Coin[] = [ - [kudos`0.1`, 0], - [kudos`1`, 0], - [kudos`2`, 0], - [kudos`5`, 0], - [kudos`10`, 0], - ]; - const result = convertWithdrawalAmountFromAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: {}, - }, - kudos`13`, - TransactionAmountMode.Raw, - ); - t.is(Amounts.stringifyValue(result.effective), "12.8"); - t.is(Amounts.stringifyValue(result.raw), "12.9"); - // coins received - // 8 x 0.1 - // 1 x 0.2 - // 1 x 10.0 - // total effective 12.8 - // fee 10 x 0.01 = 0.10 - // total raw 12.9 - // left in reserve 13 - 12.9 == 0.1 - - //current wallet impl: hides the left in reserve fee - //shows fee = 0.2 -}); - -test("demo: deposit max after withdraw raw 13", (t) => { - const coinList: Coin[] = [ - [kudos`0.1`, 8], - [kudos`1`, 0], - [kudos`2`, 1], - [kudos`5`, 0], - [kudos`10`, 1], - ]; - const result = getMaxDepositAmountForAvailableCoins( - { - list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), - exchanges: { - one: { - wireFee: kudos`0.01`, - purseFee: kudos`0.00`, - creditDeadline: AbsoluteTime.never(), - debitDeadline: AbsoluteTime.never(), - }, - }, - }, - "KUDOS", - ); - t.is(Amounts.stringifyValue(result.effective), "12.8"); - t.is(Amounts.stringifyValue(result.raw), "12.69"); - - // 8 x 0.1 - // 1 x 0.2 - // 1 x 10.0 - // total effective 12.8 - // deposit fee 10 x 0.01 = 0.10 - // wire fee 0.01 - // total raw: 12.8 - 0.11 = 12.69 - // current wallet impl fee 0.14 -}); diff --git a/packages/taler-wallet-core/src/util/coinSelection.ts b/packages/taler-wallet-core/src/util/coinSelection.ts index d3c6ffc67..bb901fd75 100644 --- a/packages/taler-wallet-core/src/util/coinSelection.ts +++ b/packages/taler-wallet-core/src/util/coinSelection.ts @@ -31,6 +31,8 @@ import { AmountJson, AmountResponse, Amounts, + AmountString, + CoinPublicKeyString, CoinStatus, ConvertAmountRequest, DenominationInfo, @@ -40,28 +42,28 @@ import { ForcedCoinSel, ForcedDenomSel, GetAmountRequest, - GetPlanForOperationRequest, j2s, Logger, parsePaytoUri, PayCoinSelection, PayMerchantInsufficientBalanceDetails, + PayPeerInsufficientBalanceDetails, strcmp, TransactionAmountMode, TransactionType, + UnblindedSignature, } from "@gnu-taler/taler-util"; import { AllowedAuditorInfo, AllowedExchangeInfo, DenominationRecord, } from "../db.js"; -import { - CoinAvailabilityRecord, - getExchangeDetails, - isWithdrawableDenom, -} from "../index.js"; +import { getExchangeDetails, isWithdrawableDenom } from "../index.js"; import { InternalWalletState } from "../internal-wallet-state.js"; -import { getMerchantPaymentBalanceDetails } from "../operations/balance.js"; +import { + getMerchantPaymentBalanceDetails, + getPeerPaymentBalanceDetailsInTx, +} from "../operations/balance.js"; import { checkDbInvariant, checkLogicInvariant } from "./invariants.js"; const logger = new Logger("coinSelection.ts"); @@ -255,7 +257,7 @@ export async function selectPayCoinsNew( wireFeeAmortization, } = req; - const [candidateDenoms, wireFeesPerExchange] = await selectCandidates( + const [candidateDenoms, wireFeesPerExchange] = await selectPayMerchantCandidates( ws, req, ); @@ -549,7 +551,7 @@ export type AvailableDenom = DenominationInfo & { numAvailable: number; }; -async function selectCandidates( +async function selectPayMerchantCandidates( ws: InternalWalletState, req: SelectPayCoinRequestNg, ): Promise<[AvailableDenom[], Record]> { @@ -797,76 +799,6 @@ export function selectForcedWithdrawalDenominations( }; } -function getCoinsFilter(req: GetPlanForOperationRequest): CoinsFilter { - switch (req.type) { - case TransactionType.Withdrawal: { - return { - exchanges: - req.exchangeUrl === undefined ? undefined : [req.exchangeUrl], - }; - } - case TransactionType.Deposit: { - const payto = parsePaytoUri(req.account); - if (!payto) { - throw Error(`wrong payto ${req.account}`); - } - return { - wireMethod: payto.targetType, - }; - } - } -} - -/** - * If the operation going to be plan subtracts - * or adds amount in the wallet db - */ -export enum OperationType { - Credit = "credit", - Debit = "debit", -} - -function getOperationType(txType: TransactionType): OperationType { - const operationType = - txType === TransactionType.Withdrawal - ? OperationType.Credit - : txType === TransactionType.Deposit - ? OperationType.Debit - : undefined; - if (!operationType) { - throw Error(`operation type ${txType} not yet supported`); - } - return operationType; -} - -interface RefreshChoice { - /** - * Amount that need to be covered - */ - gap: AmountJson; - totalFee: AmountJson; - selected: CoinInfo; - totalChangeValue: AmountJson; - refreshEffective: AmountJson; - coins: { info: CoinInfo; size: number }[]; - - // totalValue: AmountJson; - // totalDepositFee: AmountJson; - // totalRefreshFee: AmountJson; - // totalChangeContribution: AmountJson; - // totalChangeWithdrawalFee: AmountJson; -} - -interface AvailableCoins { - list: CoinInfo[]; - exchanges: Record; -} -interface SelectedCoins { - totalValue: AmountJson; - coins: { info: CoinInfo; size: number }[]; - refresh?: RefreshChoice; -} - export interface CoinInfo { id: string; value: AmountJson; @@ -880,739 +812,267 @@ export interface CoinInfo { exchangeBaseUrl: string; maxAge: number; } -interface ExchangeInfo { - wireFee: AmountJson | undefined; - purseFee: AmountJson | undefined; - creditDeadline: AbsoluteTime; - debitDeadline: AbsoluteTime; -} - -interface CoinsFilter { - shouldCalculatePurseFee?: boolean; - exchanges?: string[]; - wireMethod?: string; - ageRestricted?: number; -} -/** - * Get all the denoms that can be used for a operation that is limited - * by the following restrictions. - * This function is costly (by the database access) but with high chances - * of being cached - */ -async function getAvailableDenoms( - ws: InternalWalletState, - op: TransactionType, - currency: string, - filters: CoinsFilter = {}, -): Promise { - const operationType = getOperationType(TransactionType.Deposit); - - return await ws.db - .mktx((x) => [ - x.exchanges, - x.exchangeDetails, - x.denominations, - x.coinAvailability, - ]) - .runReadOnly(async (tx) => { - const list: CoinInfo[] = []; - const exchanges: Record = {}; - - const databaseExchanges = await tx.exchanges.iter().toArray(); - const filteredExchanges = - filters.exchanges ?? databaseExchanges.map((e) => e.baseUrl); - - for (const exchangeBaseUrl of filteredExchanges) { - const exchangeDetails = await getExchangeDetails(tx, exchangeBaseUrl); - // 1.- exchange has same currency - if (exchangeDetails?.currency !== currency) { - continue; - } - - let deadline = AbsoluteTime.never(); - // 2.- exchange supports wire method - let wireFee: AmountJson | undefined; - if (filters.wireMethod) { - const wireMethodWithDates = - exchangeDetails.wireInfo.feesForType[filters.wireMethod]; - - if (!wireMethodWithDates) { - throw Error( - `exchange ${exchangeBaseUrl} doesn't have wire method ${filters.wireMethod}`, - ); - } - const wireMethodFee = wireMethodWithDates.find((x) => { - return AbsoluteTime.isBetween( - AbsoluteTime.now(), - AbsoluteTime.fromProtocolTimestamp(x.startStamp), - AbsoluteTime.fromProtocolTimestamp(x.endStamp), - ); - }); - - if (!wireMethodFee) { - throw Error( - `exchange ${exchangeBaseUrl} doesn't have wire fee defined for this period`, - ); - } - wireFee = Amounts.parseOrThrow(wireMethodFee.wireFee); - deadline = AbsoluteTime.min( - deadline, - AbsoluteTime.fromProtocolTimestamp(wireMethodFee.endStamp), - ); - } - // exchanges[exchangeBaseUrl].wireFee = wireMethodFee; - - // 3.- exchange supports wire method - let purseFee: AmountJson | undefined; - if (filters.shouldCalculatePurseFee) { - const purseFeeFound = exchangeDetails.globalFees.find((x) => { - return AbsoluteTime.isBetween( - AbsoluteTime.now(), - AbsoluteTime.fromProtocolTimestamp(x.startDate), - AbsoluteTime.fromProtocolTimestamp(x.endDate), - ); - }); - if (!purseFeeFound) { - throw Error( - `exchange ${exchangeBaseUrl} doesn't have purse fee defined for this period`, - ); - } - purseFee = Amounts.parseOrThrow(purseFeeFound.purseFee); - deadline = AbsoluteTime.min( - deadline, - AbsoluteTime.fromProtocolTimestamp(purseFeeFound.endDate), - ); - } - - let creditDeadline = AbsoluteTime.never(); - let debitDeadline = AbsoluteTime.never(); - //4.- filter coins restricted by age - if (operationType === OperationType.Credit) { - const ds = await tx.denominations.indexes.byExchangeBaseUrl.getAll( - exchangeBaseUrl, - ); - for (const denom of ds) { - const expiresWithdraw = AbsoluteTime.fromProtocolTimestamp( - denom.stampExpireWithdraw, - ); - const expiresDeposit = AbsoluteTime.fromProtocolTimestamp( - denom.stampExpireDeposit, - ); - creditDeadline = AbsoluteTime.min(deadline, expiresWithdraw); - debitDeadline = AbsoluteTime.min(deadline, expiresDeposit); - list.push( - buildCoinInfoFromDenom( - denom, - purseFee, - wireFee, - AgeRestriction.AGE_UNRESTRICTED, - Number.MAX_SAFE_INTEGER, // Max withdrawable from single denom - ), - ); - } - } else { - const ageLower = filters.ageRestricted ?? 0; - const ageUpper = AgeRestriction.AGE_UNRESTRICTED; - - const myExchangeCoins = - await tx.coinAvailability.indexes.byExchangeAgeAvailability.getAll( - GlobalIDB.KeyRange.bound( - [exchangeDetails.exchangeBaseUrl, ageLower, 1], - [ - exchangeDetails.exchangeBaseUrl, - ageUpper, - Number.MAX_SAFE_INTEGER, - ], - ), - ); - //5.- save denoms with how many coins are available - // FIXME: Check that the individual denomination is audited! - // FIXME: Should we exclude denominations that are - // not spendable anymore? - for (const coinAvail of myExchangeCoins) { - const denom = await tx.denominations.get([ - coinAvail.exchangeBaseUrl, - coinAvail.denomPubHash, - ]); - checkDbInvariant(!!denom); - if (denom.isRevoked || !denom.isOffered) { - continue; - } - const expiresWithdraw = AbsoluteTime.fromProtocolTimestamp( - denom.stampExpireWithdraw, - ); - const expiresDeposit = AbsoluteTime.fromProtocolTimestamp( - denom.stampExpireDeposit, - ); - creditDeadline = AbsoluteTime.min(deadline, expiresWithdraw); - debitDeadline = AbsoluteTime.min(deadline, expiresDeposit); - list.push( - buildCoinInfoFromDenom( - denom, - purseFee, - wireFee, - coinAvail.maxAge, - coinAvail.freshCoinCount, - ), - ); - } - } - - exchanges[exchangeBaseUrl] = { - purseFee, - wireFee, - debitDeadline, - creditDeadline, - }; - } - - return { list, exchanges }; - }); -} -function buildCoinInfoFromDenom( - denom: DenominationRecord, - purseFee: AmountJson | undefined, - wireFee: AmountJson | undefined, - maxAge: number, - total: number, -): CoinInfo { - return { - id: denom.denomPubHash, - denomWithdraw: Amounts.parseOrThrow(denom.fees.feeWithdraw), - denomDeposit: Amounts.parseOrThrow(denom.fees.feeDeposit), - denomRefresh: Amounts.parseOrThrow(denom.fees.feeRefresh), - exchangePurse: purseFee, - exchangeWire: wireFee, - exchangeBaseUrl: denom.exchangeBaseUrl, - duration: AbsoluteTime.difference( - AbsoluteTime.now(), - AbsoluteTime.fromProtocolTimestamp(denom.stampExpireDeposit), - ), - totalAvailable: total, - value: DenominationRecord.getValue(denom), - maxAge, - }; -} - -export async function convertDepositAmount( - ws: InternalWalletState, - req: ConvertAmountRequest, -): Promise { - const amount = Amounts.parseOrThrow(req.amount); - // const filter = getCoinsFilter(req); - - const denoms = await getAvailableDenoms( - ws, - TransactionType.Deposit, - amount.currency, - {}, - ); - const result = convertDepositAmountForAvailableCoins( - denoms, - amount, - req.type, - ); - return { - effectiveAmount: Amounts.stringify(result.effective), - rawAmount: Amounts.stringify(result.raw), - }; +export interface SelectedPeerCoin { + coinPub: string; + coinPriv: string; + contribution: AmountString; + denomPubHash: string; + denomSig: UnblindedSignature; + ageCommitmentProof: AgeCommitmentProof | undefined; } -const LOG_REFRESH = false; -const LOG_DEPOSIT = false; -export function convertDepositAmountForAvailableCoins( - denoms: AvailableCoins, - amount: AmountJson, - mode: TransactionAmountMode, -): AmountAndRefresh { - const zero = Amounts.zeroOfCurrency(amount.currency); - if (!denoms.list.length) { - // no coins in the database - return { effective: zero, raw: zero }; - } - const depositDenoms = rankDenominationForDeposit(denoms.list, mode); - - //FIXME: we are not taking into account - // * exchanges with multiple accounts - // * wallet with multiple exchanges - const wireFee = Object.values(denoms.exchanges)[0]?.wireFee ?? zero; - const adjustedAmount = Amounts.add(amount, wireFee).amount; - - const selected = selectGreedyCoins(depositDenoms, adjustedAmount); - - const gap = Amounts.sub(amount, selected.totalValue).amount; - - const result = getTotalEffectiveAndRawForDeposit( - selected.coins, - amount.currency, - ); - result.raw = Amounts.sub(result.raw, wireFee).amount; - - if (Amounts.isZero(gap)) { - // exact amount founds - return result; - } - - if (LOG_DEPOSIT) { - const logInfo = selected.coins.map((c) => { - return `${Amounts.stringifyValue(c.info.id)} x ${c.size}`; - }); - console.log( - "deposit used:", - logInfo.join(", "), - "gap:", - Amounts.stringifyValue(gap), - ); - } +export interface PeerCoinSelectionDetails { + exchangeBaseUrl: string; - const refreshDenoms = rankDenominationForRefresh(denoms.list); /** - * FIXME: looking for refresh AFTER selecting greedy is not optimal + * Info of Coins that were selected. */ - const refreshCoin = searchBestRefreshCoin( - depositDenoms, - refreshDenoms, - gap, - mode, - ); - - if (refreshCoin) { - const fee = Amounts.sub(result.effective, result.raw).amount; - const effective = Amounts.add( - result.effective, - refreshCoin.refreshEffective, - ).amount; - const raw = Amounts.sub(effective, fee, refreshCoin.totalFee).amount; - //found with change - return { - effective, - raw, - refresh: refreshCoin, - }; - } + coins: SelectedPeerCoin[]; - // there is a gap, but no refresh coin was found - return result; -} - -export async function getMaxDepositAmount( - ws: InternalWalletState, - req: GetAmountRequest, -): Promise { - // const filter = getCoinsFilter(req); - - const denoms = await getAvailableDenoms( - ws, - TransactionType.Deposit, - req.currency, - {}, - ); - - const result = getMaxDepositAmountForAvailableCoins(denoms, req.currency); - return { - effectiveAmount: Amounts.stringify(result.effective), - rawAmount: Amounts.stringify(result.raw), - }; + /** + * How much of the deposit fees is the customer paying? + */ + depositFees: AmountJson; } -export function getMaxDepositAmountForAvailableCoins( - denoms: AvailableCoins, - currency: string, -) { - const zero = Amounts.zeroOfCurrency(currency); - if (!denoms.list.length) { - // no coins in the database - return { effective: zero, raw: zero }; - } - - const result = getTotalEffectiveAndRawForDeposit( - denoms.list.map((info) => { - return { info, size: info.totalAvailable ?? 0 }; - }), - currency, - ); - - const wireFee = Object.values(denoms.exchanges)[0]?.wireFee ?? zero; - result.raw = Amounts.sub(result.raw, wireFee).amount; - - return result; -} +/** + * Information about a selected coin for peer to peer payments. + */ +export interface PeerCoinInfo { + /** + * Public key of the coin. + */ + coinPub: string; -export async function convertPeerPushAmount( - ws: InternalWalletState, - req: ConvertAmountRequest, -): Promise { - throw Error("to be implemented after 1.0"); -} -export async function getMaxPeerPushAmount( - ws: InternalWalletState, - req: GetAmountRequest, -): Promise { - throw Error("to be implemented after 1.0"); -} -export async function convertWithdrawalAmount( - ws: InternalWalletState, - req: ConvertAmountRequest, -): Promise { - const amount = Amounts.parseOrThrow(req.amount); + coinPriv: string; - const denoms = await getAvailableDenoms( - ws, - TransactionType.Withdrawal, - amount.currency, - {}, - ); + /** + * Deposit fee for the coin. + */ + feeDeposit: AmountJson; - const result = convertWithdrawalAmountFromAvailableCoins( - denoms, - amount, - req.type, - ); + value: AmountJson; - return { - effectiveAmount: Amounts.stringify(result.effective), - rawAmount: Amounts.stringify(result.raw), - }; -} + denomPubHash: string; -export function convertWithdrawalAmountFromAvailableCoins( - denoms: AvailableCoins, - amount: AmountJson, - mode: TransactionAmountMode, -) { - const zero = Amounts.zeroOfCurrency(amount.currency); - if (!denoms.list.length) { - // no coins in the database - return { effective: zero, raw: zero }; - } - const withdrawDenoms = rankDenominationForWithdrawals(denoms.list, mode); + denomSig: UnblindedSignature; - const selected = selectGreedyCoins(withdrawDenoms, amount); + maxAge: number; - return getTotalEffectiveAndRawForWithdrawal(selected.coins, amount.currency); + ageCommitmentProof?: AgeCommitmentProof; } -/** ***************************************************** - * HELPERS - * ***************************************************** - */ - -/** - * - * @param depositDenoms - * @param refreshDenoms - * @param amount - * @param mode - * @returns - */ -function searchBestRefreshCoin( - depositDenoms: SelectableElement[], - refreshDenoms: Record, - amount: AmountJson, - mode: TransactionAmountMode, -): RefreshChoice | undefined { - let choice: RefreshChoice | undefined = undefined; - let refreshIdx = 0; - refreshIteration: while (refreshIdx < depositDenoms.length) { - const d = depositDenoms[refreshIdx]; - - const denomContribution = - mode === TransactionAmountMode.Effective - ? d.value - : Amounts.sub(d.value, d.info.denomRefresh, d.info.denomDeposit).amount; - - const changeAfterDeposit = Amounts.sub(denomContribution, amount).amount; - if (Amounts.isZero(changeAfterDeposit)) { - //this coin is not big enough to use for refresh - //since the list is sorted, we can break here - break refreshIteration; - } - - const withdrawDenoms = refreshDenoms[d.info.exchangeBaseUrl]; - const change = selectGreedyCoins(withdrawDenoms, changeAfterDeposit); - - const zero = Amounts.zeroOfCurrency(amount.currency); - const withdrawChangeFee = change.coins.reduce((cur, prev) => { - return Amounts.add( - cur, - Amounts.mult(prev.info.denomWithdraw, prev.size).amount, - ).amount; - }, zero); - - const withdrawChangeValue = change.coins.reduce((cur, prev) => { - return Amounts.add(cur, Amounts.mult(prev.info.value, prev.size).amount) - .amount; - }, zero); - - const totalFee = Amounts.add( - d.info.denomDeposit, - d.info.denomRefresh, - withdrawChangeFee, - ).amount; +export type SelectPeerCoinsResult = + | { type: "success"; result: PeerCoinSelectionDetails } + | { + type: "failure"; + insufficientBalanceDetails: PayPeerInsufficientBalanceDetails; + }; - if (!choice || Amounts.cmp(totalFee, choice.totalFee) === -1) { - //found cheaper change - choice = { - gap: amount, - totalFee: totalFee, - totalChangeValue: change.totalValue, //change after refresh - refreshEffective: Amounts.sub(d.info.value, withdrawChangeValue).amount, // what of the denom used is not recovered - selected: d.info, - coins: change.coins, - }; - } - refreshIdx++; - } - if (choice) { - if (LOG_REFRESH) { - const logInfo = choice.coins.map((c) => { - return `${Amounts.stringifyValue(c.info.id)} x ${c.size}`; - }); - console.log( - "refresh used:", - Amounts.stringifyValue(choice.selected.value), - "change:", - logInfo.join(", "), - "fee:", - Amounts.stringifyValue(choice.totalFee), - "refreshEffective:", - Amounts.stringifyValue(choice.refreshEffective), - "totalChangeValue:", - Amounts.stringifyValue(choice.totalChangeValue), - ); - } - } - return choice; +export interface PeerCoinRepair { + exchangeBaseUrl: string; + coinPubs: CoinPublicKeyString[]; + contribs: AmountJson[]; } -/** - * Returns a copy of the list sorted for the best denom to withdraw first - * - * @param denoms - * @returns - */ -function rankDenominationForWithdrawals( - denoms: CoinInfo[], - mode: TransactionAmountMode, -): SelectableElement[] { - const copyList = [...denoms]; - /** - * Rank coins - */ - copyList.sort((d1, d2) => { - // the best coin to use is - // 1.- the one that contrib more and pay less fee - // 2.- it takes more time before expires - - //different exchanges may have different wireFee - //ranking should take the relative contribution in the exchange - //which is (value - denomFee / fixedFee) - const rate1 = Amounts.divmod(d1.value, d1.denomWithdraw).quotient; - const rate2 = Amounts.divmod(d2.value, d2.denomWithdraw).quotient; - const contribCmp = rate1 === rate2 ? 0 : rate1 < rate2 ? 1 : -1; - return ( - contribCmp || - Duration.cmp(d1.duration, d2.duration) || - strcmp(d1.id, d2.id) - ); - }); - - return copyList.map((info) => { - switch (mode) { - case TransactionAmountMode.Effective: { - //if the user instructed "effective" then we need to selected - //greedy total coin value - return { - info, - value: info.value, - total: Number.MAX_SAFE_INTEGER, - }; - } - case TransactionAmountMode.Raw: { - //if the user instructed "raw" then we need to selected - //greedy total coin raw amount (without fee) - return { - info, - value: Amounts.add(info.value, info.denomWithdraw).amount, - total: Number.MAX_SAFE_INTEGER, - }; - } - } - }); -} +export interface PeerCoinSelectionRequest { + instructedAmount: AmountJson; -/** - * Returns a copy of the list sorted for the best denom to deposit first - * - * @param denoms - * @returns - */ -function rankDenominationForDeposit( - denoms: CoinInfo[], - mode: TransactionAmountMode, -): SelectableElement[] { - const copyList = [...denoms]; /** - * Rank coins + * Instruct the coin selection to repair this coin + * selection instead of selecting completely new coins. */ - copyList.sort((d1, d2) => { - // the best coin to use is - // 1.- the one that contrib more and pay less fee - // 2.- it takes more time before expires - - //different exchanges may have different wireFee - //ranking should take the relative contribution in the exchange - //which is (value - denomFee / fixedFee) - const rate1 = Amounts.divmod(d1.value, d1.denomDeposit).quotient; - const rate2 = Amounts.divmod(d2.value, d2.denomDeposit).quotient; - const contribCmp = rate1 === rate2 ? 0 : rate1 < rate2 ? 1 : -1; - return ( - contribCmp || - Duration.cmp(d1.duration, d2.duration) || - strcmp(d1.id, d2.id) - ); - }); - - return copyList.map((info) => { - switch (mode) { - case TransactionAmountMode.Effective: { - //if the user instructed "effective" then we need to selected - //greedy total coin value - return { - info, - value: info.value, - total: info.totalAvailable ?? 0, - }; - } - case TransactionAmountMode.Raw: { - //if the user instructed "raw" then we need to selected - //greedy total coin raw amount (without fee) - return { - info, - value: Amounts.sub(info.value, info.denomDeposit).amount, - total: info.totalAvailable ?? 0, - }; - } - } - }); + repair?: PeerCoinRepair; } -/** - * Returns a copy of the list sorted for the best denom to withdraw first - * - * @param denoms - * @returns - */ -function rankDenominationForRefresh( - denoms: CoinInfo[], -): Record { - const groupByExchange: Record = {}; - for (const d of denoms) { - if (!groupByExchange[d.exchangeBaseUrl]) { - groupByExchange[d.exchangeBaseUrl] = []; - } - groupByExchange[d.exchangeBaseUrl].push(d); - } - - const result: Record = {}; - for (const d of denoms) { - result[d.exchangeBaseUrl] = rankDenominationForWithdrawals( - groupByExchange[d.exchangeBaseUrl], - TransactionAmountMode.Raw, - ); +export async function selectPeerCoins( + ws: InternalWalletState, + req: PeerCoinSelectionRequest, +): Promise { + const instructedAmount = req.instructedAmount; + if (Amounts.isZero(instructedAmount)) { + // Other parts of the code assume that we have at least + // one coin to spend. + throw new Error("amount of zero not allowed"); } - return result; -} - -interface SelectableElement { - total: number; - value: AmountJson; - info: CoinInfo; -} - -function selectGreedyCoins( - coins: SelectableElement[], - limit: AmountJson, -): SelectedCoins { - const result: SelectedCoins = { - totalValue: Amounts.zeroOfCurrency(limit.currency), - coins: [], - }; - if (!coins.length) return result; - - let denomIdx = 0; - iterateDenoms: while (denomIdx < coins.length) { - const denom = coins[denomIdx]; - // let total = denom.total; - const left = Amounts.sub(limit, result.totalValue).amount; + return await ws.db + .mktx((x) => [ + x.exchanges, + x.contractTerms, + x.coins, + x.coinAvailability, + x.denominations, + x.refreshGroups, + x.peerPushPaymentInitiations, + ]) + .runReadWrite(async (tx) => { + const exchanges = await tx.exchanges.iter().toArray(); + const exchangeFeeGap: { [url: string]: AmountJson } = {}; + const currency = Amounts.currencyOf(instructedAmount); + for (const exch of exchanges) { + if (exch.detailsPointer?.currency !== currency) { + continue; + } + // FIXME: Can't we do this faster by using coinAvailability? + const coins = ( + await tx.coins.indexes.byBaseUrl.getAll(exch.baseUrl) + ).filter((x) => x.status === CoinStatus.Fresh); + const coinInfos: PeerCoinInfo[] = []; + for (const coin of coins) { + const denom = await ws.getDenomInfo( + ws, + tx, + coin.exchangeBaseUrl, + coin.denomPubHash, + ); + if (!denom) { + throw Error("denom not found"); + } + coinInfos.push({ + coinPub: coin.coinPub, + feeDeposit: Amounts.parseOrThrow(denom.feeDeposit), + value: Amounts.parseOrThrow(denom.value), + denomPubHash: denom.denomPubHash, + coinPriv: coin.coinPriv, + denomSig: coin.denomSig, + maxAge: coin.maxAge, + ageCommitmentProof: coin.ageCommitmentProof, + }); + } + if (coinInfos.length === 0) { + continue; + } + coinInfos.sort( + (o1, o2) => + -Amounts.cmp(o1.value, o2.value) || + strcmp(o1.denomPubHash, o2.denomPubHash), + ); + let amountAcc = Amounts.zeroOfCurrency(currency); + let depositFeesAcc = Amounts.zeroOfCurrency(currency); + const resCoins: { + coinPub: string; + coinPriv: string; + contribution: AmountString; + denomPubHash: string; + denomSig: UnblindedSignature; + ageCommitmentProof: AgeCommitmentProof | undefined; + }[] = []; + let lastDepositFee = Amounts.zeroOfCurrency(currency); + + if (req.repair) { + for (let i = 0; i < req.repair.coinPubs.length; i++) { + const contrib = req.repair.contribs[i]; + const coin = await tx.coins.get(req.repair.coinPubs[i]); + if (!coin) { + throw Error("repair not possible, coin not found"); + } + const denom = await ws.getDenomInfo( + ws, + tx, + coin.exchangeBaseUrl, + coin.denomPubHash, + ); + checkDbInvariant(!!denom); + resCoins.push({ + coinPriv: coin.coinPriv, + coinPub: coin.coinPub, + contribution: Amounts.stringify(contrib), + denomPubHash: coin.denomPubHash, + denomSig: coin.denomSig, + ageCommitmentProof: coin.ageCommitmentProof, + }); + const depositFee = Amounts.parseOrThrow(denom.feeDeposit); + lastDepositFee = depositFee; + amountAcc = Amounts.add( + amountAcc, + Amounts.sub(contrib, depositFee).amount, + ).amount; + depositFeesAcc = Amounts.add(depositFeesAcc, depositFee).amount; + } + } - if (Amounts.isZero(denom.value)) { - // 0 contribution denoms should be the last - break iterateDenoms; - } + for (const coin of coinInfos) { + if (Amounts.cmp(amountAcc, instructedAmount) >= 0) { + break; + } + const gap = Amounts.add( + coin.feeDeposit, + Amounts.sub(instructedAmount, amountAcc).amount, + ).amount; + const contrib = Amounts.min(gap, coin.value); + amountAcc = Amounts.add( + amountAcc, + Amounts.sub(contrib, coin.feeDeposit).amount, + ).amount; + depositFeesAcc = Amounts.add(depositFeesAcc, coin.feeDeposit).amount; + resCoins.push({ + coinPriv: coin.coinPriv, + coinPub: coin.coinPub, + contribution: Amounts.stringify(contrib), + denomPubHash: coin.denomPubHash, + denomSig: coin.denomSig, + ageCommitmentProof: coin.ageCommitmentProof, + }); + lastDepositFee = coin.feeDeposit; + } + if (Amounts.cmp(amountAcc, instructedAmount) >= 0) { + const res: PeerCoinSelectionDetails = { + exchangeBaseUrl: exch.baseUrl, + coins: resCoins, + depositFees: depositFeesAcc, + }; + return { type: "success", result: res }; + } + const diff = Amounts.sub(instructedAmount, amountAcc).amount; + exchangeFeeGap[exch.baseUrl] = Amounts.add(lastDepositFee, diff).amount; - //use Amounts.divmod instead of iterate - const div = Amounts.divmod(left, denom.value); - const size = Math.min(div.quotient, denom.total); - if (size > 0) { - const mul = Amounts.mult(denom.value, size).amount; - const progress = Amounts.add(result.totalValue, mul).amount; + continue; + } - result.totalValue = progress; - result.coins.push({ info: denom.info, size }); - denom.total = denom.total - size; - } + // We were unable to select coins. + // Now we need to produce error details. - //go next denom - denomIdx++; - } + const infoGeneral = await getPeerPaymentBalanceDetailsInTx(ws, tx, { + currency, + }); - return result; -} + const perExchange: PayPeerInsufficientBalanceDetails["perExchange"] = {}; -type AmountWithFee = { raw: AmountJson; effective: AmountJson }; -type AmountAndRefresh = AmountWithFee & { refresh?: RefreshChoice }; + let maxFeeGapEstimate = Amounts.zeroOfCurrency(currency); -export function getTotalEffectiveAndRawForDeposit( - list: { info: CoinInfo; size: number }[], - currency: string, -): AmountWithFee { - const init = { - raw: Amounts.zeroOfCurrency(currency), - effective: Amounts.zeroOfCurrency(currency), - }; - return list.reduce((prev, cur) => { - const ef = Amounts.mult(cur.info.value, cur.size).amount; - const rw = Amounts.mult( - Amounts.sub(cur.info.value, cur.info.denomDeposit).amount, - cur.size, - ).amount; + for (const exch of exchanges) { + if (exch.detailsPointer?.currency !== currency) { + continue; + } + const infoExchange = await getPeerPaymentBalanceDetailsInTx(ws, tx, { + currency, + restrictExchangeTo: exch.baseUrl, + }); + let gap = + exchangeFeeGap[exch.baseUrl] ?? Amounts.zeroOfCurrency(currency); + if (Amounts.cmp(infoExchange.balanceMaterial, instructedAmount) < 0) { + // Show fee gap only if we should've been able to pay with the material amount + gap = Amounts.zeroOfCurrency(currency); + } + perExchange[exch.baseUrl] = { + balanceAvailable: Amounts.stringify(infoExchange.balanceAvailable), + balanceMaterial: Amounts.stringify(infoExchange.balanceMaterial), + feeGapEstimate: Amounts.stringify(gap), + }; - prev.effective = Amounts.add(prev.effective, ef).amount; - prev.raw = Amounts.add(prev.raw, rw).amount; - return prev; - }, init); -} + maxFeeGapEstimate = Amounts.max(maxFeeGapEstimate, gap); + } -function getTotalEffectiveAndRawForWithdrawal( - list: { info: CoinInfo; size: number }[], - currency: string, -): AmountWithFee { - const init = { - raw: Amounts.zeroOfCurrency(currency), - effective: Amounts.zeroOfCurrency(currency), - }; - return list.reduce((prev, cur) => { - const ef = Amounts.mult(cur.info.value, cur.size).amount; - const rw = Amounts.mult( - Amounts.add(cur.info.value, cur.info.denomWithdraw).amount, - cur.size, - ).amount; + const errDetails: PayPeerInsufficientBalanceDetails = { + amountRequested: Amounts.stringify(instructedAmount), + balanceAvailable: Amounts.stringify(infoGeneral.balanceAvailable), + balanceMaterial: Amounts.stringify(infoGeneral.balanceMaterial), + feeGapEstimate: Amounts.stringify(maxFeeGapEstimate), + perExchange, + }; - prev.effective = Amounts.add(prev.effective, ef).amount; - prev.raw = Amounts.add(prev.raw, rw).amount; - return prev; - }, init); + return { type: "failure", insufficientBalanceDetails: errDetails }; + }); } diff --git a/packages/taler-wallet-core/src/util/instructedAmountConversion.test.ts b/packages/taler-wallet-core/src/util/instructedAmountConversion.test.ts new file mode 100644 index 000000000..de8515d09 --- /dev/null +++ b/packages/taler-wallet-core/src/util/instructedAmountConversion.test.ts @@ -0,0 +1,763 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ +import { + AbsoluteTime, + AgeRestriction, + AmountJson, + Amounts, + Duration, + TransactionAmountMode, +} from "@gnu-taler/taler-util"; +import test, { ExecutionContext } from "ava"; +import { CoinInfo } from "./coinSelection.js"; +import { convertDepositAmountForAvailableCoins, getMaxDepositAmountForAvailableCoins, convertWithdrawalAmountFromAvailableCoins } from "./instructedAmountConversion.js"; + +function makeCurrencyHelper(currency: string) { + return (sx: TemplateStringsArray, ...vx: any[]) => { + const s = String.raw({ raw: sx }, ...vx); + return Amounts.parseOrThrow(`${currency}:${s}`); + }; +} + +const kudos = makeCurrencyHelper("kudos"); + +function defaultFeeConfig(value: AmountJson, totalAvailable: number): CoinInfo { + return { + id: Amounts.stringify(value), + denomDeposit: kudos`0.01`, + denomRefresh: kudos`0.01`, + denomWithdraw: kudos`0.01`, + exchangeBaseUrl: "1", + duration: Duration.getForever(), + exchangePurse: undefined, + exchangeWire: undefined, + maxAge: AgeRestriction.AGE_UNRESTRICTED, + totalAvailable, + value, + }; +} +type Coin = [AmountJson, number]; + +/** + * Making a deposit with effective amount + * + */ + +test("deposit effective 2", (t) => { + const coinList: Coin[] = [ + [kudos`2`, 5], + [kudos`5`, 5], + ]; + const result = convertDepositAmountForAvailableCoins( + { + list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + exchanges: {}, + }, + kudos`2`, + TransactionAmountMode.Effective, + ); + t.is(Amounts.stringifyValue(result.effective), "2"); + t.is(Amounts.stringifyValue(result.raw), "1.99"); +}); + +test("deposit effective 10", (t) => { + const coinList: Coin[] = [ + [kudos`2`, 5], + [kudos`5`, 5], + ]; + const result = convertDepositAmountForAvailableCoins( + { + list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + exchanges: {}, + }, + kudos`10`, + TransactionAmountMode.Effective, + ); + t.is(Amounts.stringifyValue(result.effective), "10"); + t.is(Amounts.stringifyValue(result.raw), "9.98"); +}); + +test("deposit effective 24", (t) => { + const coinList: Coin[] = [ + [kudos`2`, 5], + [kudos`5`, 5], + ]; + const result = convertDepositAmountForAvailableCoins( + { + list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + exchanges: {}, + }, + kudos`24`, + TransactionAmountMode.Effective, + ); + t.is(Amounts.stringifyValue(result.effective), "24"); + t.is(Amounts.stringifyValue(result.raw), "23.94"); +}); + +test("deposit effective 40", (t) => { + const coinList: Coin[] = [ + [kudos`2`, 5], + [kudos`5`, 5], + ]; + const result = convertDepositAmountForAvailableCoins( + { + list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + exchanges: {}, + }, + kudos`40`, + TransactionAmountMode.Effective, + ); + t.is(Amounts.stringifyValue(result.effective), "35"); + t.is(Amounts.stringifyValue(result.raw), "34.9"); +}); + +test("deposit with wire fee effective 2", (t) => { + const coinList: Coin[] = [ + [kudos`2`, 5], + [kudos`5`, 5], + ]; + const result = convertDepositAmountForAvailableCoins( + { + list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + exchanges: { + one: { + wireFee: kudos`0.1`, + purseFee: kudos`0.00`, + creditDeadline: AbsoluteTime.never(), + debitDeadline: AbsoluteTime.never(), + }, + }, + }, + kudos`2`, + TransactionAmountMode.Effective, + ); + t.is(Amounts.stringifyValue(result.effective), "2"); + t.is(Amounts.stringifyValue(result.raw), "1.89"); +}); + +/** + * Making a deposit with raw amount, using the result from effective + * + */ + +test("deposit raw 1.99 (effective 2)", (t) => { + const coinList: Coin[] = [ + [kudos`2`, 5], + [kudos`5`, 5], + ]; + const result = convertDepositAmountForAvailableCoins( + { + list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + exchanges: {}, + }, + kudos`1.99`, + TransactionAmountMode.Raw, + ); + t.is(Amounts.stringifyValue(result.effective), "2"); + t.is(Amounts.stringifyValue(result.raw), "1.99"); +}); + +test("deposit raw 9.98 (effective 10)", (t) => { + const coinList: Coin[] = [ + [kudos`2`, 5], + [kudos`5`, 5], + ]; + const result = convertDepositAmountForAvailableCoins( + { + list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + exchanges: {}, + }, + kudos`9.98`, + TransactionAmountMode.Raw, + ); + t.is(Amounts.stringifyValue(result.effective), "10"); + t.is(Amounts.stringifyValue(result.raw), "9.98"); +}); + +test("deposit raw 23.94 (effective 24)", (t) => { + const coinList: Coin[] = [ + [kudos`2`, 5], + [kudos`5`, 5], + ]; + const result = convertDepositAmountForAvailableCoins( + { + list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + exchanges: {}, + }, + kudos`23.94`, + TransactionAmountMode.Raw, + ); + t.is(Amounts.stringifyValue(result.effective), "24"); + t.is(Amounts.stringifyValue(result.raw), "23.94"); +}); + +test("deposit raw 34.9 (effective 40)", (t) => { + const coinList: Coin[] = [ + [kudos`2`, 5], + [kudos`5`, 5], + ]; + const result = convertDepositAmountForAvailableCoins( + { + list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + exchanges: {}, + }, + kudos`34.9`, + TransactionAmountMode.Raw, + ); + t.is(Amounts.stringifyValue(result.effective), "35"); + t.is(Amounts.stringifyValue(result.raw), "34.9"); +}); + +test("deposit with wire fee raw 2", (t) => { + const coinList: Coin[] = [ + [kudos`2`, 5], + [kudos`5`, 5], + ]; + const result = convertDepositAmountForAvailableCoins( + { + list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + exchanges: { + one: { + wireFee: kudos`0.1`, + purseFee: kudos`0.00`, + creditDeadline: AbsoluteTime.never(), + debitDeadline: AbsoluteTime.never(), + }, + }, + }, + kudos`2`, + TransactionAmountMode.Effective, + ); + t.is(Amounts.stringifyValue(result.effective), "2"); + t.is(Amounts.stringifyValue(result.raw), "1.89"); +}); + +/** + * Calculating the max amount possible to deposit + * + */ + +test("deposit max 35", (t) => { + const coinList: Coin[] = [ + [kudos`2`, 5], + [kudos`5`, 5], + ]; + const result = getMaxDepositAmountForAvailableCoins( + { + list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + exchanges: { + "2": { + wireFee: kudos`0.00`, + purseFee: kudos`0.00`, + creditDeadline: AbsoluteTime.never(), + debitDeadline: AbsoluteTime.never(), + }, + }, + }, + "KUDOS", + ); + t.is(Amounts.stringifyValue(result.raw), "34.9"); + t.is(Amounts.stringifyValue(result.effective), "35"); +}); + +test("deposit max 35 with wirefee", (t) => { + const coinList: Coin[] = [ + [kudos`2`, 5], + [kudos`5`, 5], + ]; + const result = getMaxDepositAmountForAvailableCoins( + { + list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + exchanges: { + "2": { + wireFee: kudos`1`, + purseFee: kudos`0.00`, + creditDeadline: AbsoluteTime.never(), + debitDeadline: AbsoluteTime.never(), + }, + }, + }, + "KUDOS", + ); + t.is(Amounts.stringifyValue(result.raw), "33.9"); + t.is(Amounts.stringifyValue(result.effective), "35"); +}); + +test("deposit max repeated denom", (t) => { + const coinList: Coin[] = [ + [kudos`2`, 1], + [kudos`2`, 1], + [kudos`5`, 1], + ]; + const result = getMaxDepositAmountForAvailableCoins( + { + list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + exchanges: { + "2": { + wireFee: kudos`0.00`, + purseFee: kudos`0.00`, + creditDeadline: AbsoluteTime.never(), + debitDeadline: AbsoluteTime.never(), + }, + }, + }, + "KUDOS", + ); + t.is(Amounts.stringifyValue(result.raw), "8.97"); + t.is(Amounts.stringifyValue(result.effective), "9"); +}); + +/** + * Making a withdrawal with effective amount + * + */ + +test("withdraw effective 2", (t) => { + const coinList: Coin[] = [ + [kudos`2`, 5], + [kudos`5`, 5], + ]; + const result = convertWithdrawalAmountFromAvailableCoins( + { + list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + exchanges: {}, + }, + kudos`2`, + TransactionAmountMode.Effective, + ); + t.is(Amounts.stringifyValue(result.effective), "2"); + t.is(Amounts.stringifyValue(result.raw), "2.01"); +}); + +test("withdraw effective 10", (t) => { + const coinList: Coin[] = [ + [kudos`2`, 5], + [kudos`5`, 5], + ]; + const result = convertWithdrawalAmountFromAvailableCoins( + { + list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + exchanges: {}, + }, + kudos`10`, + TransactionAmountMode.Effective, + ); + t.is(Amounts.stringifyValue(result.effective), "10"); + t.is(Amounts.stringifyValue(result.raw), "10.02"); +}); + +test("withdraw effective 24", (t) => { + const coinList: Coin[] = [ + [kudos`2`, 5], + [kudos`5`, 5], + ]; + const result = convertWithdrawalAmountFromAvailableCoins( + { + list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + exchanges: {}, + }, + kudos`24`, + TransactionAmountMode.Effective, + ); + t.is(Amounts.stringifyValue(result.effective), "24"); + t.is(Amounts.stringifyValue(result.raw), "24.06"); +}); + +test("withdraw effective 40", (t) => { + const coinList: Coin[] = [ + [kudos`2`, 5], + [kudos`5`, 5], + ]; + const result = convertWithdrawalAmountFromAvailableCoins( + { + list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + exchanges: {}, + }, + kudos`40`, + TransactionAmountMode.Effective, + ); + t.is(Amounts.stringifyValue(result.effective), "40"); + t.is(Amounts.stringifyValue(result.raw), "40.08"); +}); + +/** + * Making a deposit with raw amount, using the result from effective + * + */ + +test("withdraw raw 2.01 (effective 2)", (t) => { + const coinList: Coin[] = [ + [kudos`2`, 5], + [kudos`5`, 5], + ]; + const result = convertWithdrawalAmountFromAvailableCoins( + { + list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + exchanges: {}, + }, + kudos`2.01`, + TransactionAmountMode.Raw, + ); + t.is(Amounts.stringifyValue(result.effective), "2"); + t.is(Amounts.stringifyValue(result.raw), "2.01"); +}); + +test("withdraw raw 10.02 (effective 10)", (t) => { + const coinList: Coin[] = [ + [kudos`2`, 5], + [kudos`5`, 5], + ]; + const result = convertWithdrawalAmountFromAvailableCoins( + { + list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + exchanges: {}, + }, + kudos`10.02`, + TransactionAmountMode.Raw, + ); + t.is(Amounts.stringifyValue(result.effective), "10"); + t.is(Amounts.stringifyValue(result.raw), "10.02"); +}); + +test("withdraw raw 24.06 (effective 24)", (t) => { + const coinList: Coin[] = [ + [kudos`2`, 5], + [kudos`5`, 5], + ]; + const result = convertWithdrawalAmountFromAvailableCoins( + { + list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + exchanges: {}, + }, + kudos`24.06`, + TransactionAmountMode.Raw, + ); + t.is(Amounts.stringifyValue(result.effective), "24"); + t.is(Amounts.stringifyValue(result.raw), "24.06"); +}); + +test("withdraw raw 40.08 (effective 40)", (t) => { + const coinList: Coin[] = [ + [kudos`2`, 5], + [kudos`5`, 5], + ]; + const result = convertWithdrawalAmountFromAvailableCoins( + { + list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + exchanges: {}, + }, + kudos`40.08`, + TransactionAmountMode.Raw, + ); + t.is(Amounts.stringifyValue(result.effective), "40"); + t.is(Amounts.stringifyValue(result.raw), "40.08"); +}); + +test("withdraw raw 25", (t) => { + const coinList: Coin[] = [ + [kudos`0.1`, 0], + [kudos`1`, 0], + [kudos`2`, 0], + [kudos`5`, 0], + ]; + const result = convertWithdrawalAmountFromAvailableCoins( + { + list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + exchanges: {}, + }, + kudos`25`, + TransactionAmountMode.Raw, + ); + t.is(Amounts.stringifyValue(result.effective), "24.8"); + t.is(Amounts.stringifyValue(result.raw), "24.94"); +}); + +test("withdraw effective 24.8 (raw 25)", (t) => { + const coinList: Coin[] = [ + [kudos`0.1`, 0], + [kudos`1`, 0], + [kudos`2`, 0], + [kudos`5`, 0], + ]; + const result = convertWithdrawalAmountFromAvailableCoins( + { + list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + exchanges: {}, + }, + kudos`24.8`, + TransactionAmountMode.Effective, + ); + t.is(Amounts.stringifyValue(result.effective), "24.8"); + t.is(Amounts.stringifyValue(result.raw), "24.94"); +}); + +/** + * Making a deposit with refresh + * + */ + +test("deposit with refresh: effective 3", (t) => { + const coinList: Coin[] = [ + [kudos`0.1`, 0], + [kudos`1`, 0], + [kudos`2`, 5], + [kudos`5`, 5], + ]; + const result = convertDepositAmountForAvailableCoins( + { + list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + exchanges: {}, + }, + kudos`3`, + TransactionAmountMode.Effective, + ); + t.is(Amounts.stringifyValue(result.effective), "3.1"); + t.is(Amounts.stringifyValue(result.raw), "2.98"); + expectDefined(t, result.refresh); + //FEES + //deposit 2 x 0.01 + //refresh 1 x 0.01 + //withdraw 9 x 0.01 + //----------------- + //op 0.12 + + //coins sent 2 x 2.0 + //coins recv 9 x 0.1 + //------------------- + //effective 3.10 + //raw 2.98 + t.is(Amounts.stringifyValue(result.refresh.selected.id), "2"); + t.deepEqual(asCoinList(result.refresh.coins), [[kudos`0.1`, 9]]); +}); + +test("deposit with refresh: raw 2.98 (effective 3)", (t) => { + const coinList: Coin[] = [ + [kudos`0.1`, 0], + [kudos`1`, 0], + [kudos`2`, 5], + [kudos`5`, 5], + ]; + const result = convertDepositAmountForAvailableCoins( + { + list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + exchanges: {}, + }, + kudos`2.98`, + TransactionAmountMode.Raw, + ); + t.is(Amounts.stringifyValue(result.effective), "3.2"); + t.is(Amounts.stringifyValue(result.raw), "3.09"); + expectDefined(t, result.refresh); + //FEES + //deposit 1 x 0.01 + //refresh 1 x 0.01 + //withdraw 8 x 0.01 + //----------------- + //op 0.10 + + //coins sent 1 x 2.0 + //coins recv 8 x 0.1 + //------------------- + //effective 3.20 + //raw 3.09 + t.is(Amounts.stringifyValue(result.refresh.selected.id), "2"); + t.deepEqual(asCoinList(result.refresh.coins), [[kudos`0.1`, 8]]); +}); + +test("deposit with refresh: effective 3.2 (raw 2.98)", (t) => { + const coinList: Coin[] = [ + [kudos`0.1`, 0], + [kudos`1`, 0], + [kudos`2`, 5], + [kudos`5`, 5], + ]; + const result = convertDepositAmountForAvailableCoins( + { + list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + exchanges: {}, + }, + kudos`3.2`, + TransactionAmountMode.Effective, + ); + t.is(Amounts.stringifyValue(result.effective), "3.3"); + t.is(Amounts.stringifyValue(result.raw), "3.2"); + expectDefined(t, result.refresh); + //FEES + //deposit 2 x 0.01 + //refresh 1 x 0.01 + //withdraw 7 x 0.01 + //----------------- + //op 0.10 + + //coins sent 2 x 2.0 + //coins recv 7 x 0.1 + //------------------- + //effective 3.30 + //raw 3.20 + t.is(Amounts.stringifyValue(result.refresh.selected.id), "2"); + t.deepEqual(asCoinList(result.refresh.coins), [[kudos`0.1`, 7]]); +}); + +function expectDefined( + t: ExecutionContext, + v: T | undefined, +): asserts v is T { + t.assert(v !== undefined); +} + +function asCoinList(v: { info: CoinInfo; size: number }[]): any { + return v.map((c) => { + return [c.info.value, c.size]; + }); +} + +/** + * regression tests + */ + +test("demo: withdraw raw 25", (t) => { + const coinList: Coin[] = [ + [kudos`0.1`, 0], + [kudos`1`, 0], + [kudos`2`, 0], + [kudos`5`, 0], + [kudos`10`, 0], + ]; + const result = convertWithdrawalAmountFromAvailableCoins( + { + list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + exchanges: {}, + }, + kudos`25`, + TransactionAmountMode.Raw, + ); + t.is(Amounts.stringifyValue(result.effective), "24.8"); + t.is(Amounts.stringifyValue(result.raw), "24.92"); + // coins received + // 8 x 0.1 + // 2 x 0.2 + // 2 x 10.0 + // total effective 24.8 + // fee 12 x 0.01 = 0.12 + // total raw 24.92 + // left in reserve 25 - 24.92 == 0.08 + + //current wallet impl: hides the left in reserve fee + //shows fee = 0.2 +}); + +test("demo: deposit max after withdraw raw 25", (t) => { + const coinList: Coin[] = [ + [kudos`0.1`, 8], + [kudos`1`, 0], + [kudos`2`, 2], + [kudos`5`, 0], + [kudos`10`, 2], + ]; + const result = getMaxDepositAmountForAvailableCoins( + { + list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + exchanges: { + one: { + wireFee: kudos`0.01`, + purseFee: kudos`0.00`, + creditDeadline: AbsoluteTime.never(), + debitDeadline: AbsoluteTime.never(), + }, + }, + }, + "KUDOS", + ); + t.is(Amounts.stringifyValue(result.effective), "24.8"); + t.is(Amounts.stringifyValue(result.raw), "24.67"); + + // 8 x 0.1 + // 2 x 0.2 + // 2 x 10.0 + // total effective 24.8 + // deposit fee 12 x 0.01 = 0.12 + // wire fee 0.01 + // total raw: 24.8 - 0.13 = 24.67 + + // current wallet impl fee 0.14 +}); + +test("demo: withdraw raw 13", (t) => { + const coinList: Coin[] = [ + [kudos`0.1`, 0], + [kudos`1`, 0], + [kudos`2`, 0], + [kudos`5`, 0], + [kudos`10`, 0], + ]; + const result = convertWithdrawalAmountFromAvailableCoins( + { + list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + exchanges: {}, + }, + kudos`13`, + TransactionAmountMode.Raw, + ); + t.is(Amounts.stringifyValue(result.effective), "12.8"); + t.is(Amounts.stringifyValue(result.raw), "12.9"); + // coins received + // 8 x 0.1 + // 1 x 0.2 + // 1 x 10.0 + // total effective 12.8 + // fee 10 x 0.01 = 0.10 + // total raw 12.9 + // left in reserve 13 - 12.9 == 0.1 + + //current wallet impl: hides the left in reserve fee + //shows fee = 0.2 +}); + +test("demo: deposit max after withdraw raw 13", (t) => { + const coinList: Coin[] = [ + [kudos`0.1`, 8], + [kudos`1`, 0], + [kudos`2`, 1], + [kudos`5`, 0], + [kudos`10`, 1], + ]; + const result = getMaxDepositAmountForAvailableCoins( + { + list: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + exchanges: { + one: { + wireFee: kudos`0.01`, + purseFee: kudos`0.00`, + creditDeadline: AbsoluteTime.never(), + debitDeadline: AbsoluteTime.never(), + }, + }, + }, + "KUDOS", + ); + t.is(Amounts.stringifyValue(result.effective), "12.8"); + t.is(Amounts.stringifyValue(result.raw), "12.69"); + + // 8 x 0.1 + // 1 x 0.2 + // 1 x 10.0 + // total effective 12.8 + // deposit fee 10 x 0.01 = 0.10 + // wire fee 0.01 + // total raw: 12.8 - 0.11 = 12.69 + + // current wallet impl fee 0.14 +}); diff --git a/packages/taler-wallet-core/src/util/instructedAmountConversion.ts b/packages/taler-wallet-core/src/util/instructedAmountConversion.ts new file mode 100644 index 000000000..bd02e7b22 --- /dev/null +++ b/packages/taler-wallet-core/src/util/instructedAmountConversion.ts @@ -0,0 +1,849 @@ +/* + This file is part of GNU Taler + (C) 2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +import { + AbsoluteTime, + AgeRestriction, + AmountJson, + AmountResponse, + Amounts, + ConvertAmountRequest, + Duration, + GetAmountRequest, + GetPlanForOperationRequest, + TransactionAmountMode, + TransactionType, + parsePaytoUri, + strcmp, +} from "@gnu-taler/taler-util"; +import { checkDbInvariant } from "./invariants.js"; +import { + DenominationRecord, + InternalWalletState, + getExchangeDetails, +} from "../index.js"; +import { CoinInfo } from "./coinSelection.js"; +import { GlobalIDB } from "@gnu-taler/idb-bridge"; + +/** + * If the operation going to be plan subtracts + * or adds amount in the wallet db + */ +export enum OperationType { + Credit = "credit", + Debit = "debit", +} + +// FIXME: Name conflict ... +interface ExchangeInfo { + wireFee: AmountJson | undefined; + purseFee: AmountJson | undefined; + creditDeadline: AbsoluteTime; + debitDeadline: AbsoluteTime; +} + +function getOperationType(txType: TransactionType): OperationType { + const operationType = + txType === TransactionType.Withdrawal + ? OperationType.Credit + : txType === TransactionType.Deposit + ? OperationType.Debit + : undefined; + if (!operationType) { + throw Error(`operation type ${txType} not yet supported`); + } + return operationType; +} + +interface SelectedCoins { + totalValue: AmountJson; + coins: { info: CoinInfo; size: number }[]; + refresh?: RefreshChoice; +} + +function getCoinsFilter(req: GetPlanForOperationRequest): CoinsFilter { + switch (req.type) { + case TransactionType.Withdrawal: { + return { + exchanges: + req.exchangeUrl === undefined ? undefined : [req.exchangeUrl], + }; + } + case TransactionType.Deposit: { + const payto = parsePaytoUri(req.account); + if (!payto) { + throw Error(`wrong payto ${req.account}`); + } + return { + wireMethod: payto.targetType, + }; + } + } +} + +interface RefreshChoice { + /** + * Amount that need to be covered + */ + gap: AmountJson; + totalFee: AmountJson; + selected: CoinInfo; + totalChangeValue: AmountJson; + refreshEffective: AmountJson; + coins: { info: CoinInfo; size: number }[]; + + // totalValue: AmountJson; + // totalDepositFee: AmountJson; + // totalRefreshFee: AmountJson; + // totalChangeContribution: AmountJson; + // totalChangeWithdrawalFee: AmountJson; +} + +interface CoinsFilter { + shouldCalculatePurseFee?: boolean; + exchanges?: string[]; + wireMethod?: string; + ageRestricted?: number; +} + +interface AvailableCoins { + list: CoinInfo[]; + exchanges: Record; +} + +/** + * Get all the denoms that can be used for a operation that is limited + * by the following restrictions. + * This function is costly (by the database access) but with high chances + * of being cached + */ +async function getAvailableDenoms( + ws: InternalWalletState, + op: TransactionType, + currency: string, + filters: CoinsFilter = {}, +): Promise { + const operationType = getOperationType(TransactionType.Deposit); + + return await ws.db + .mktx((x) => [ + x.exchanges, + x.exchangeDetails, + x.denominations, + x.coinAvailability, + ]) + .runReadOnly(async (tx) => { + const list: CoinInfo[] = []; + const exchanges: Record = {}; + + const databaseExchanges = await tx.exchanges.iter().toArray(); + const filteredExchanges = + filters.exchanges ?? databaseExchanges.map((e) => e.baseUrl); + + for (const exchangeBaseUrl of filteredExchanges) { + const exchangeDetails = await getExchangeDetails(tx, exchangeBaseUrl); + // 1.- exchange has same currency + if (exchangeDetails?.currency !== currency) { + continue; + } + + let deadline = AbsoluteTime.never(); + // 2.- exchange supports wire method + let wireFee: AmountJson | undefined; + if (filters.wireMethod) { + const wireMethodWithDates = + exchangeDetails.wireInfo.feesForType[filters.wireMethod]; + + if (!wireMethodWithDates) { + throw Error( + `exchange ${exchangeBaseUrl} doesn't have wire method ${filters.wireMethod}`, + ); + } + const wireMethodFee = wireMethodWithDates.find((x) => { + return AbsoluteTime.isBetween( + AbsoluteTime.now(), + AbsoluteTime.fromProtocolTimestamp(x.startStamp), + AbsoluteTime.fromProtocolTimestamp(x.endStamp), + ); + }); + + if (!wireMethodFee) { + throw Error( + `exchange ${exchangeBaseUrl} doesn't have wire fee defined for this period`, + ); + } + wireFee = Amounts.parseOrThrow(wireMethodFee.wireFee); + deadline = AbsoluteTime.min( + deadline, + AbsoluteTime.fromProtocolTimestamp(wireMethodFee.endStamp), + ); + } + // exchanges[exchangeBaseUrl].wireFee = wireMethodFee; + + // 3.- exchange supports wire method + let purseFee: AmountJson | undefined; + if (filters.shouldCalculatePurseFee) { + const purseFeeFound = exchangeDetails.globalFees.find((x) => { + return AbsoluteTime.isBetween( + AbsoluteTime.now(), + AbsoluteTime.fromProtocolTimestamp(x.startDate), + AbsoluteTime.fromProtocolTimestamp(x.endDate), + ); + }); + if (!purseFeeFound) { + throw Error( + `exchange ${exchangeBaseUrl} doesn't have purse fee defined for this period`, + ); + } + purseFee = Amounts.parseOrThrow(purseFeeFound.purseFee); + deadline = AbsoluteTime.min( + deadline, + AbsoluteTime.fromProtocolTimestamp(purseFeeFound.endDate), + ); + } + + let creditDeadline = AbsoluteTime.never(); + let debitDeadline = AbsoluteTime.never(); + //4.- filter coins restricted by age + if (operationType === OperationType.Credit) { + const ds = await tx.denominations.indexes.byExchangeBaseUrl.getAll( + exchangeBaseUrl, + ); + for (const denom of ds) { + const expiresWithdraw = AbsoluteTime.fromProtocolTimestamp( + denom.stampExpireWithdraw, + ); + const expiresDeposit = AbsoluteTime.fromProtocolTimestamp( + denom.stampExpireDeposit, + ); + creditDeadline = AbsoluteTime.min(deadline, expiresWithdraw); + debitDeadline = AbsoluteTime.min(deadline, expiresDeposit); + list.push( + buildCoinInfoFromDenom( + denom, + purseFee, + wireFee, + AgeRestriction.AGE_UNRESTRICTED, + Number.MAX_SAFE_INTEGER, // Max withdrawable from single denom + ), + ); + } + } else { + const ageLower = filters.ageRestricted ?? 0; + const ageUpper = AgeRestriction.AGE_UNRESTRICTED; + + const myExchangeCoins = + await tx.coinAvailability.indexes.byExchangeAgeAvailability.getAll( + GlobalIDB.KeyRange.bound( + [exchangeDetails.exchangeBaseUrl, ageLower, 1], + [ + exchangeDetails.exchangeBaseUrl, + ageUpper, + Number.MAX_SAFE_INTEGER, + ], + ), + ); + //5.- save denoms with how many coins are available + // FIXME: Check that the individual denomination is audited! + // FIXME: Should we exclude denominations that are + // not spendable anymore? + for (const coinAvail of myExchangeCoins) { + const denom = await tx.denominations.get([ + coinAvail.exchangeBaseUrl, + coinAvail.denomPubHash, + ]); + checkDbInvariant(!!denom); + if (denom.isRevoked || !denom.isOffered) { + continue; + } + const expiresWithdraw = AbsoluteTime.fromProtocolTimestamp( + denom.stampExpireWithdraw, + ); + const expiresDeposit = AbsoluteTime.fromProtocolTimestamp( + denom.stampExpireDeposit, + ); + creditDeadline = AbsoluteTime.min(deadline, expiresWithdraw); + debitDeadline = AbsoluteTime.min(deadline, expiresDeposit); + list.push( + buildCoinInfoFromDenom( + denom, + purseFee, + wireFee, + coinAvail.maxAge, + coinAvail.freshCoinCount, + ), + ); + } + } + + exchanges[exchangeBaseUrl] = { + purseFee, + wireFee, + debitDeadline, + creditDeadline, + }; + } + + return { list, exchanges }; + }); +} + +function buildCoinInfoFromDenom( + denom: DenominationRecord, + purseFee: AmountJson | undefined, + wireFee: AmountJson | undefined, + maxAge: number, + total: number, +): CoinInfo { + return { + id: denom.denomPubHash, + denomWithdraw: Amounts.parseOrThrow(denom.fees.feeWithdraw), + denomDeposit: Amounts.parseOrThrow(denom.fees.feeDeposit), + denomRefresh: Amounts.parseOrThrow(denom.fees.feeRefresh), + exchangePurse: purseFee, + exchangeWire: wireFee, + exchangeBaseUrl: denom.exchangeBaseUrl, + duration: AbsoluteTime.difference( + AbsoluteTime.now(), + AbsoluteTime.fromProtocolTimestamp(denom.stampExpireDeposit), + ), + totalAvailable: total, + value: DenominationRecord.getValue(denom), + maxAge, + }; +} + +export async function convertDepositAmount( + ws: InternalWalletState, + req: ConvertAmountRequest, +): Promise { + const amount = Amounts.parseOrThrow(req.amount); + // const filter = getCoinsFilter(req); + + const denoms = await getAvailableDenoms( + ws, + TransactionType.Deposit, + amount.currency, + {}, + ); + const result = convertDepositAmountForAvailableCoins( + denoms, + amount, + req.type, + ); + + return { + effectiveAmount: Amounts.stringify(result.effective), + rawAmount: Amounts.stringify(result.raw), + }; +} + +const LOG_REFRESH = false; +const LOG_DEPOSIT = false; +export function convertDepositAmountForAvailableCoins( + denoms: AvailableCoins, + amount: AmountJson, + mode: TransactionAmountMode, +): AmountAndRefresh { + const zero = Amounts.zeroOfCurrency(amount.currency); + if (!denoms.list.length) { + // no coins in the database + return { effective: zero, raw: zero }; + } + const depositDenoms = rankDenominationForDeposit(denoms.list, mode); + + //FIXME: we are not taking into account + // * exchanges with multiple accounts + // * wallet with multiple exchanges + const wireFee = Object.values(denoms.exchanges)[0]?.wireFee ?? zero; + const adjustedAmount = Amounts.add(amount, wireFee).amount; + + const selected = selectGreedyCoins(depositDenoms, adjustedAmount); + + const gap = Amounts.sub(amount, selected.totalValue).amount; + + const result = getTotalEffectiveAndRawForDeposit( + selected.coins, + amount.currency, + ); + result.raw = Amounts.sub(result.raw, wireFee).amount; + + if (Amounts.isZero(gap)) { + // exact amount founds + return result; + } + + if (LOG_DEPOSIT) { + const logInfo = selected.coins.map((c) => { + return `${Amounts.stringifyValue(c.info.id)} x ${c.size}`; + }); + console.log( + "deposit used:", + logInfo.join(", "), + "gap:", + Amounts.stringifyValue(gap), + ); + } + + const refreshDenoms = rankDenominationForRefresh(denoms.list); + /** + * FIXME: looking for refresh AFTER selecting greedy is not optimal + */ + const refreshCoin = searchBestRefreshCoin( + depositDenoms, + refreshDenoms, + gap, + mode, + ); + + if (refreshCoin) { + const fee = Amounts.sub(result.effective, result.raw).amount; + const effective = Amounts.add( + result.effective, + refreshCoin.refreshEffective, + ).amount; + const raw = Amounts.sub(effective, fee, refreshCoin.totalFee).amount; + //found with change + return { + effective, + raw, + refresh: refreshCoin, + }; + } + + // there is a gap, but no refresh coin was found + return result; +} + +export async function getMaxDepositAmount( + ws: InternalWalletState, + req: GetAmountRequest, +): Promise { + // const filter = getCoinsFilter(req); + + const denoms = await getAvailableDenoms( + ws, + TransactionType.Deposit, + req.currency, + {}, + ); + + const result = getMaxDepositAmountForAvailableCoins(denoms, req.currency); + return { + effectiveAmount: Amounts.stringify(result.effective), + rawAmount: Amounts.stringify(result.raw), + }; +} + +export function getMaxDepositAmountForAvailableCoins( + denoms: AvailableCoins, + currency: string, +) { + const zero = Amounts.zeroOfCurrency(currency); + if (!denoms.list.length) { + // no coins in the database + return { effective: zero, raw: zero }; + } + + const result = getTotalEffectiveAndRawForDeposit( + denoms.list.map((info) => { + return { info, size: info.totalAvailable ?? 0 }; + }), + currency, + ); + + const wireFee = Object.values(denoms.exchanges)[0]?.wireFee ?? zero; + result.raw = Amounts.sub(result.raw, wireFee).amount; + + return result; +} + +export async function convertPeerPushAmount( + ws: InternalWalletState, + req: ConvertAmountRequest, +): Promise { + throw Error("to be implemented after 1.0"); +} +export async function getMaxPeerPushAmount( + ws: InternalWalletState, + req: GetAmountRequest, +): Promise { + throw Error("to be implemented after 1.0"); +} +export async function convertWithdrawalAmount( + ws: InternalWalletState, + req: ConvertAmountRequest, +): Promise { + const amount = Amounts.parseOrThrow(req.amount); + + const denoms = await getAvailableDenoms( + ws, + TransactionType.Withdrawal, + amount.currency, + {}, + ); + + const result = convertWithdrawalAmountFromAvailableCoins( + denoms, + amount, + req.type, + ); + + return { + effectiveAmount: Amounts.stringify(result.effective), + rawAmount: Amounts.stringify(result.raw), + }; +} + +export function convertWithdrawalAmountFromAvailableCoins( + denoms: AvailableCoins, + amount: AmountJson, + mode: TransactionAmountMode, +) { + const zero = Amounts.zeroOfCurrency(amount.currency); + if (!denoms.list.length) { + // no coins in the database + return { effective: zero, raw: zero }; + } + const withdrawDenoms = rankDenominationForWithdrawals(denoms.list, mode); + + const selected = selectGreedyCoins(withdrawDenoms, amount); + + return getTotalEffectiveAndRawForWithdrawal(selected.coins, amount.currency); +} + +/** ***************************************************** + * HELPERS + * ***************************************************** + */ + +/** + * + * @param depositDenoms + * @param refreshDenoms + * @param amount + * @param mode + * @returns + */ +function searchBestRefreshCoin( + depositDenoms: SelectableElement[], + refreshDenoms: Record, + amount: AmountJson, + mode: TransactionAmountMode, +): RefreshChoice | undefined { + let choice: RefreshChoice | undefined = undefined; + let refreshIdx = 0; + refreshIteration: while (refreshIdx < depositDenoms.length) { + const d = depositDenoms[refreshIdx]; + + const denomContribution = + mode === TransactionAmountMode.Effective + ? d.value + : Amounts.sub(d.value, d.info.denomRefresh, d.info.denomDeposit).amount; + + const changeAfterDeposit = Amounts.sub(denomContribution, amount).amount; + if (Amounts.isZero(changeAfterDeposit)) { + //this coin is not big enough to use for refresh + //since the list is sorted, we can break here + break refreshIteration; + } + + const withdrawDenoms = refreshDenoms[d.info.exchangeBaseUrl]; + const change = selectGreedyCoins(withdrawDenoms, changeAfterDeposit); + + const zero = Amounts.zeroOfCurrency(amount.currency); + const withdrawChangeFee = change.coins.reduce((cur, prev) => { + return Amounts.add( + cur, + Amounts.mult(prev.info.denomWithdraw, prev.size).amount, + ).amount; + }, zero); + + const withdrawChangeValue = change.coins.reduce((cur, prev) => { + return Amounts.add(cur, Amounts.mult(prev.info.value, prev.size).amount) + .amount; + }, zero); + + const totalFee = Amounts.add( + d.info.denomDeposit, + d.info.denomRefresh, + withdrawChangeFee, + ).amount; + + if (!choice || Amounts.cmp(totalFee, choice.totalFee) === -1) { + //found cheaper change + choice = { + gap: amount, + totalFee: totalFee, + totalChangeValue: change.totalValue, //change after refresh + refreshEffective: Amounts.sub(d.info.value, withdrawChangeValue).amount, // what of the denom used is not recovered + selected: d.info, + coins: change.coins, + }; + } + refreshIdx++; + } + if (choice) { + if (LOG_REFRESH) { + const logInfo = choice.coins.map((c) => { + return `${Amounts.stringifyValue(c.info.id)} x ${c.size}`; + }); + console.log( + "refresh used:", + Amounts.stringifyValue(choice.selected.value), + "change:", + logInfo.join(", "), + "fee:", + Amounts.stringifyValue(choice.totalFee), + "refreshEffective:", + Amounts.stringifyValue(choice.refreshEffective), + "totalChangeValue:", + Amounts.stringifyValue(choice.totalChangeValue), + ); + } + } + return choice; +} + +/** + * Returns a copy of the list sorted for the best denom to withdraw first + * + * @param denoms + * @returns + */ +function rankDenominationForWithdrawals( + denoms: CoinInfo[], + mode: TransactionAmountMode, +): SelectableElement[] { + const copyList = [...denoms]; + /** + * Rank coins + */ + copyList.sort((d1, d2) => { + // the best coin to use is + // 1.- the one that contrib more and pay less fee + // 2.- it takes more time before expires + + //different exchanges may have different wireFee + //ranking should take the relative contribution in the exchange + //which is (value - denomFee / fixedFee) + const rate1 = Amounts.divmod(d1.value, d1.denomWithdraw).quotient; + const rate2 = Amounts.divmod(d2.value, d2.denomWithdraw).quotient; + const contribCmp = rate1 === rate2 ? 0 : rate1 < rate2 ? 1 : -1; + return ( + contribCmp || + Duration.cmp(d1.duration, d2.duration) || + strcmp(d1.id, d2.id) + ); + }); + + return copyList.map((info) => { + switch (mode) { + case TransactionAmountMode.Effective: { + //if the user instructed "effective" then we need to selected + //greedy total coin value + return { + info, + value: info.value, + total: Number.MAX_SAFE_INTEGER, + }; + } + case TransactionAmountMode.Raw: { + //if the user instructed "raw" then we need to selected + //greedy total coin raw amount (without fee) + return { + info, + value: Amounts.add(info.value, info.denomWithdraw).amount, + total: Number.MAX_SAFE_INTEGER, + }; + } + } + }); +} + +/** + * Returns a copy of the list sorted for the best denom to deposit first + * + * @param denoms + * @returns + */ +function rankDenominationForDeposit( + denoms: CoinInfo[], + mode: TransactionAmountMode, +): SelectableElement[] { + const copyList = [...denoms]; + /** + * Rank coins + */ + copyList.sort((d1, d2) => { + // the best coin to use is + // 1.- the one that contrib more and pay less fee + // 2.- it takes more time before expires + + //different exchanges may have different wireFee + //ranking should take the relative contribution in the exchange + //which is (value - denomFee / fixedFee) + const rate1 = Amounts.divmod(d1.value, d1.denomDeposit).quotient; + const rate2 = Amounts.divmod(d2.value, d2.denomDeposit).quotient; + const contribCmp = rate1 === rate2 ? 0 : rate1 < rate2 ? 1 : -1; + return ( + contribCmp || + Duration.cmp(d1.duration, d2.duration) || + strcmp(d1.id, d2.id) + ); + }); + + return copyList.map((info) => { + switch (mode) { + case TransactionAmountMode.Effective: { + //if the user instructed "effective" then we need to selected + //greedy total coin value + return { + info, + value: info.value, + total: info.totalAvailable ?? 0, + }; + } + case TransactionAmountMode.Raw: { + //if the user instructed "raw" then we need to selected + //greedy total coin raw amount (without fee) + return { + info, + value: Amounts.sub(info.value, info.denomDeposit).amount, + total: info.totalAvailable ?? 0, + }; + } + } + }); +} + +/** + * Returns a copy of the list sorted for the best denom to withdraw first + * + * @param denoms + * @returns + */ +function rankDenominationForRefresh( + denoms: CoinInfo[], +): Record { + const groupByExchange: Record = {}; + for (const d of denoms) { + if (!groupByExchange[d.exchangeBaseUrl]) { + groupByExchange[d.exchangeBaseUrl] = []; + } + groupByExchange[d.exchangeBaseUrl].push(d); + } + + const result: Record = {}; + for (const d of denoms) { + result[d.exchangeBaseUrl] = rankDenominationForWithdrawals( + groupByExchange[d.exchangeBaseUrl], + TransactionAmountMode.Raw, + ); + } + return result; +} + +interface SelectableElement { + total: number; + value: AmountJson; + info: CoinInfo; +} + +function selectGreedyCoins( + coins: SelectableElement[], + limit: AmountJson, +): SelectedCoins { + const result: SelectedCoins = { + totalValue: Amounts.zeroOfCurrency(limit.currency), + coins: [], + }; + if (!coins.length) return result; + + let denomIdx = 0; + iterateDenoms: while (denomIdx < coins.length) { + const denom = coins[denomIdx]; + // let total = denom.total; + const left = Amounts.sub(limit, result.totalValue).amount; + + if (Amounts.isZero(denom.value)) { + // 0 contribution denoms should be the last + break iterateDenoms; + } + + //use Amounts.divmod instead of iterate + const div = Amounts.divmod(left, denom.value); + const size = Math.min(div.quotient, denom.total); + if (size > 0) { + const mul = Amounts.mult(denom.value, size).amount; + const progress = Amounts.add(result.totalValue, mul).amount; + + result.totalValue = progress; + result.coins.push({ info: denom.info, size }); + denom.total = denom.total - size; + } + + //go next denom + denomIdx++; + } + + return result; +} + +type AmountWithFee = { raw: AmountJson; effective: AmountJson }; +type AmountAndRefresh = AmountWithFee & { refresh?: RefreshChoice }; + +export function getTotalEffectiveAndRawForDeposit( + list: { info: CoinInfo; size: number }[], + currency: string, +): AmountWithFee { + const init = { + raw: Amounts.zeroOfCurrency(currency), + effective: Amounts.zeroOfCurrency(currency), + }; + return list.reduce((prev, cur) => { + const ef = Amounts.mult(cur.info.value, cur.size).amount; + const rw = Amounts.mult( + Amounts.sub(cur.info.value, cur.info.denomDeposit).amount, + cur.size, + ).amount; + + prev.effective = Amounts.add(prev.effective, ef).amount; + prev.raw = Amounts.add(prev.raw, rw).amount; + return prev; + }, init); +} + +function getTotalEffectiveAndRawForWithdrawal( + list: { info: CoinInfo; size: number }[], + currency: string, +): AmountWithFee { + const init = { + raw: Amounts.zeroOfCurrency(currency), + effective: Amounts.zeroOfCurrency(currency), + }; + return list.reduce((prev, cur) => { + const ef = Amounts.mult(cur.info.value, cur.size).amount; + const rw = Amounts.mult( + Amounts.add(cur.info.value, cur.info.denomWithdraw).amount, + cur.size, + ).amount; + + prev.effective = Amounts.add(prev.effective, ef).amount; + prev.raw = Amounts.add(prev.raw, rw).amount; + return prev; + }, init); +} diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index b967571d0..bff4442b6 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -276,13 +276,6 @@ import { } from "./operations/withdraw.js"; import { PendingTaskInfo, PendingTaskType } from "./pending-types.js"; import { assertUnreachable } from "./util/assertUnreachable.js"; -import { - convertDepositAmount, - convertPeerPushAmount, - convertWithdrawalAmount, - getMaxDepositAmount, - getMaxPeerPushAmount, -} from "./util/coinSelection.js"; import { createTimeline, selectBestForOverlappingDenominations, @@ -313,6 +306,13 @@ import { WalletCoreApiClient, WalletCoreResponseType, } from "./wallet-api-types.js"; +import { + convertDepositAmount, + getMaxDepositAmount, + convertPeerPushAmount, + getMaxPeerPushAmount, + convertWithdrawalAmount, +} from "./util/instructedAmountConversion.js"; const logger = new Logger("wallet.ts"); -- cgit v1.2.3