taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 6d9b300703d82093f340e6498aeb970fd811beba
parent c67217eea575b863b92b63061e2c7152d07dedbb
Author: Florian Dold <florian@dold.me>
Date:   Mon, 21 Oct 2024 18:57:31 +0200

wallet-core: improve scopeInfo support for p2p transactions

Diffstat:
Mpackages/taler-util/src/types-taler-wallet.ts | 48+++++++++++++++++++++++++++++++++++++++++-------
Mpackages/taler-wallet-core/src/exchanges.ts | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/pay-peer-pull-credit.ts | 83++++++++++++++++++++++++++-----------------------------------------------------
Mpackages/taler-wallet-core/src/pay-peer-pull-debit.ts | 40+++++++++++++++++++++++++++++++++-------
Mpackages/taler-wallet-core/src/pay-peer-push-credit.ts | 23+++++++++++++++++------
Mpackages/taler-wallet-core/src/pay-peer-push-debit.ts | 17++++++++++++++++-
Mpackages/taler-wallet-core/src/withdraw.ts | 16+++++++++++++++-
Mpackages/taler-wallet-webextension/src/cta/Withdraw/test.ts | 4+++-
8 files changed, 265 insertions(+), 79 deletions(-)

diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -1498,6 +1498,11 @@ export interface AcceptManualWithdrawalResult { export interface WithdrawalDetailsForAmount { /** + * Exchange base URL for the withdrawal. + */ + exchangeBaseUrl: string; + + /** * Did the user accept the current version of the exchange's * terms of service? * @@ -1839,8 +1844,12 @@ export const codecForAcceptManualWithdrawalRequest = .build("AcceptManualWithdrawalRequest"); export interface GetWithdrawalDetailsForAmountRequest { - exchangeBaseUrl: string; + exchangeBaseUrl?: string; + + restrictScope?: ScopeInfo; + amount: AmountString; + restrictAge?: number; /** @@ -1917,7 +1926,8 @@ export const codecForAcceptBankIntegratedWithdrawalRequest = export const codecForGetWithdrawalDetailsForAmountRequest = (): Codec<GetWithdrawalDetailsForAmountRequest> => buildCodecForObject<GetWithdrawalDetailsForAmountRequest>() - .property("exchangeBaseUrl", codecForCanonBaseUrl()) + .property("exchangeBaseUrl", codecOptional(codecForCanonBaseUrl())) + .property("restrictScope", codecOptional(codecForScopeInfo())) .property("amount", codecForAmountString()) .property("restrictAge", codecOptional(codecForNumber())) .property("clientCancellationId", codecOptional(codecForString())) @@ -2824,14 +2834,21 @@ export const codecForCheckPeerPushDebitRequest = (): Codec<CheckPeerPushDebitRequest> => buildCodecForObject<CheckPeerPushDebitRequest>() .property("exchangeBaseUrl", codecOptional(codecForCanonBaseUrl())) + .property("restrictScope", codecOptional(codecForScopeInfo())) .property("amount", codecForAmountString()) .property("clientCancellationId", codecOptional(codecForString())) .build("CheckPeerPushDebitRequest"); export interface CheckPeerPushDebitResponse { amountRaw: AmountString; + amountEffective: AmountString; + + /** + * Exchange base URL. + */ exchangeBaseUrl: string; + /** * Maximum expiration date, based on how close the coins * used for the payment are to expiry. @@ -2887,6 +2904,8 @@ export interface PreparePeerPushCreditResponse { exchangeBaseUrl: string; + scopeInfo: ScopeInfo; + /** * @deprecated use transaction ID instead. */ @@ -2900,17 +2919,25 @@ export interface PreparePeerPushCreditResponse { export interface PreparePeerPullDebitResponse { contractTerms: PeerContractTerms; - /** - * @deprecated Redundant field with bad name, will be removed soon. - */ - amount: AmountString; amountRaw: AmountString; amountEffective: AmountString; + transactionId: TransactionIdStr; + + exchangeBaseUrl: string; + + scopeInfo: ScopeInfo; + + /** + * @deprecated Use transactionId instead + */ peerPullDebitId: string; - transactionId: TransactionIdStr; + /** + * @deprecated Redundant field with bad name, will be removed soon. + */ + amount: AmountString; } export const codecForPreparePeerPushCreditRequest = @@ -2963,7 +2990,13 @@ export const codecForAcceptPeerPullPaymentRequest = .build("ConfirmPeerPullDebitRequest"); export interface CheckPeerPullCreditRequest { + /** + * Require using this particular exchange for this operation. + */ exchangeBaseUrl?: string; + + restrictScope?: ScopeInfo; + amount: AmountString; /** @@ -2985,6 +3018,7 @@ export const codecForPreparePeerPullPaymentRequest = buildCodecForObject<CheckPeerPullCreditRequest>() .property("amount", codecForAmountString()) .property("exchangeBaseUrl", codecOptional(codecForCanonBaseUrl())) + .property("restrictScope", codecOptional(codecForScopeInfo())) .property("clientCancellationId", codecOptional(codecForString())) .build("CheckPeerPullCreditRequest"); diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts @@ -3640,3 +3640,116 @@ export async function checkExchangeInScopeTx( throw Error("auditor scope not supported yet"); } } + +/** + * Find a preferred exchange based on when we withdrew last from this exchange. + */ +export async function getPreferredExchangeForCurrency( + wex: WalletExecutionContext, + currency: string, +): Promise<string | undefined> { + // Find an exchange with the matching currency. + // Prefer exchanges with the most recent withdrawal. + const url = await wex.db.runReadOnlyTx( + { storeNames: ["exchanges"] }, + async (tx) => { + const exchanges = await tx.exchanges.iter().toArray(); + let candidate = undefined; + for (const e of exchanges) { + if (e.detailsPointer?.currency !== currency) { + continue; + } + if (!candidate) { + candidate = e; + continue; + } + if (candidate.lastWithdrawal && !e.lastWithdrawal) { + continue; + } + const exchangeLastWithdrawal = timestampOptionalPreciseFromDb( + e.lastWithdrawal, + ); + const candidateLastWithdrawal = timestampOptionalPreciseFromDb( + candidate.lastWithdrawal, + ); + if (exchangeLastWithdrawal && candidateLastWithdrawal) { + if ( + AbsoluteTime.cmp( + AbsoluteTime.fromPreciseTimestamp(exchangeLastWithdrawal), + AbsoluteTime.fromPreciseTimestamp(candidateLastWithdrawal), + ) > 0 + ) { + candidate = e; + } + } + } + if (candidate) { + return candidate.baseUrl; + } + return undefined; + }, + ); + return url; +} + +/** + * Find a preferred exchange based on when we withdrew last from this exchange. + */ +export async function getPreferredExchangeForScope( + wex: WalletExecutionContext, + scope: ScopeInfo, +): Promise<string | undefined> { + // Find an exchange with the matching currency. + // Prefer exchanges with the most recent withdrawal. + const url = await wex.db.runReadOnlyTx( + { + storeNames: [ + "exchanges", + "globalCurrencyAuditors", + "globalCurrencyExchanges", + "exchangeDetails", + ], + }, + async (tx) => { + const exchanges = await tx.exchanges.iter().toArray(); + let candidate = undefined; + for (const e of exchanges) { + const inScope = await checkExchangeInScopeTx(wex, tx, e.baseUrl, scope); + if (!inScope) { + continue; + } + if (e.detailsPointer?.currency !== scope.currency) { + continue; + } + if (!candidate) { + candidate = e; + continue; + } + if (candidate.lastWithdrawal && !e.lastWithdrawal) { + continue; + } + const exchangeLastWithdrawal = timestampOptionalPreciseFromDb( + e.lastWithdrawal, + ); + const candidateLastWithdrawal = timestampOptionalPreciseFromDb( + candidate.lastWithdrawal, + ); + if (exchangeLastWithdrawal && candidateLastWithdrawal) { + if ( + AbsoluteTime.cmp( + AbsoluteTime.fromPreciseTimestamp(exchangeLastWithdrawal), + AbsoluteTime.fromPreciseTimestamp(candidateLastWithdrawal), + ) > 0 + ) { + candidate = e; + } + } + } + if (candidate) { + return candidate.baseUrl; + } + return undefined; + }, + ); + return url; +} diff --git a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts @@ -18,7 +18,6 @@ * Imports. */ import { - AbsoluteTime, Amounts, CheckPeerPullCreditRequest, CheckPeerPullCreditResponse, @@ -31,6 +30,8 @@ import { Logger, NotificationType, PeerContractTerms, + ScopeInfo, + ScopeType, TalerErrorCode, TalerErrorDetail, TalerPreciseTimestamp, @@ -84,7 +85,6 @@ import { WithdrawalGroupRecord, WithdrawalGroupStatus, WithdrawalRecordType, - timestampOptionalPreciseFromDb, timestampPreciseFromDb, timestampPreciseToDb, } from "./db.js"; @@ -92,6 +92,8 @@ import { BalanceThresholdCheckResult, checkIncomingAmountLegalUnderKycBalanceThreshold, fetchFreshExchange, + getPreferredExchangeForCurrency, + getPreferredExchangeForScope, getScopeForAllExchanges, handleStartExchangeWalletKyc, } from "./exchanges.js"; @@ -1346,14 +1348,34 @@ export async function internalCheckPeerPullCredit( ): Promise<CheckPeerPullCreditResponse> { // FIXME: We don't support exchanges with purse fees yet. // Select an exchange where we have money in the specified currency - // FIXME: How do we handle regional currency scopes here? Is it an additional input? + + const instructedAmount = Amounts.parseOrThrow(req.amount); + const currency = instructedAmount.currency; + + logger.trace( + `checking peer push debit for ${Amounts.stringify(instructedAmount)}`, + ); + + let restrictScope: ScopeInfo; + if (req.restrictScope) { + restrictScope = req.restrictScope; + } else if (req.exchangeBaseUrl) { + restrictScope = { + type: ScopeType.Exchange, + currency, + url: req.exchangeBaseUrl, + }; + } else { + throw Error("client must either specify exchangeBaseUrl or restrictScope"); + } logger.trace("checking peer-pull-credit fees"); - const currency = Amounts.currencyOf(req.amount); - let exchangeUrl; + let exchangeUrl: string | undefined; if (req.exchangeBaseUrl) { exchangeUrl = req.exchangeBaseUrl; + } else if (req.restrictScope) { + exchangeUrl = await getPreferredExchangeForScope(wex, req.restrictScope); } else { exchangeUrl = await getPreferredExchangeForCurrency(wex, currency); } @@ -1392,57 +1414,6 @@ export async function internalCheckPeerPullCredit( } /** - * Find a preferred exchange based on when we withdrew last from this exchange. - */ -async function getPreferredExchangeForCurrency( - wex: WalletExecutionContext, - currency: string, -): Promise<string | undefined> { - // Find an exchange with the matching currency. - // Prefer exchanges with the most recent withdrawal. - const url = await wex.db.runReadOnlyTx( - { storeNames: ["exchanges"] }, - async (tx) => { - const exchanges = await tx.exchanges.iter().toArray(); - let candidate = undefined; - for (const e of exchanges) { - if (e.detailsPointer?.currency !== currency) { - continue; - } - if (!candidate) { - candidate = e; - continue; - } - if (candidate.lastWithdrawal && !e.lastWithdrawal) { - continue; - } - const exchangeLastWithdrawal = timestampOptionalPreciseFromDb( - e.lastWithdrawal, - ); - const candidateLastWithdrawal = timestampOptionalPreciseFromDb( - candidate.lastWithdrawal, - ); - if (exchangeLastWithdrawal && candidateLastWithdrawal) { - if ( - AbsoluteTime.cmp( - AbsoluteTime.fromPreciseTimestamp(exchangeLastWithdrawal), - AbsoluteTime.fromPreciseTimestamp(candidateLastWithdrawal), - ) > 0 - ) { - candidate = e; - } - } - } - if (candidate) { - return candidate.baseUrl; - } - return undefined; - }, - ); - return url; -} - -/** * Initiate a peer pull payment. */ export async function initiatePeerPullPayment( diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts @@ -90,7 +90,7 @@ import { timestampPreciseFromDb, timestampPreciseToDb, } from "./db.js"; -import { getScopeForAllExchanges } from "./exchanges.js"; +import { getExchangeScopeInfo, getScopeForAllExchanges } from "./exchanges.js"; import { codecForExchangePurseStatus, getTotalPeerPaymentCost, @@ -897,7 +897,16 @@ export async function preparePeerPullDebit( } const existing = await wex.db.runReadOnlyTx( - { storeNames: ["peerPullDebit", "contractTerms"] }, + { + storeNames: [ + "peerPullDebit", + "contractTerms", + "exchangeDetails", + "exchanges", + "globalCurrencyAuditors", + "globalCurrencyExchanges", + ], + }, async (tx) => { const peerPullDebitRecord = await tx.peerPullDebit.indexes.byExchangeAndContractPriv.get([ @@ -913,7 +922,18 @@ export async function preparePeerPullDebit( if (!contractTerms) { return; } - return { peerPullDebitRecord, contractTerms }; + const currency = Amounts.currencyOf(peerPullDebitRecord.amount); + const scopeInfo = await getExchangeScopeInfo( + tx, + peerPullDebitRecord.exchangeBaseUrl, + currency, + ); + return { + peerPullDebitRecord, + contractTerms, + scopeInfo, + exchangeBaseUrl: peerPullDebitRecord.exchangeBaseUrl, + }; }, ); @@ -924,6 +944,8 @@ export async function preparePeerPullDebit( amountEffective: existing.peerPullDebitRecord.totalCostEstimated, contractTerms: existing.contractTerms.contractTermsRaw, peerPullDebitId: existing.peerPullDebitRecord.peerPullDebitId, + scopeInfo: existing.scopeInfo, + exchangeBaseUrl: existing.exchangeBaseUrl, transactionId: constructTransactionIdentifier({ tag: TransactionType.PeerPullDebit, peerPullDebitId: existing.peerPullDebitRecord.peerPullDebitId, @@ -1008,6 +1030,7 @@ export async function preparePeerPullDebit( } const totalAmount = await getTotalPeerPaymentCost(wex, coins); + const currency = Amounts.currencyOf(totalAmount); const ctx = new PeerPullDebitTransactionContext(wex, peerPullDebitId); @@ -1033,16 +1056,19 @@ export async function preparePeerPullDebit( }, ); + const scopeInfo = await wex.db.runAllStoresReadOnlyTx({}, (tx) => { + return getExchangeScopeInfo(tx, exchangeBaseUrl, currency); + }); + return { amount: contractTerms.amount, amountEffective: Amounts.stringify(totalAmount), amountRaw: contractTerms.amount, contractTerms: contractTerms, peerPullDebitId, - transactionId: constructTransactionIdentifier({ - tag: TransactionType.PeerPullDebit, - peerPullDebitId: peerPullDebitId, - }), + scopeInfo, + exchangeBaseUrl, + transactionId: ctx.transactionId, }; } diff --git a/packages/taler-wallet-core/src/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/pay-peer-push-credit.ts @@ -29,6 +29,7 @@ import { PeerContractTerms, PreparePeerPushCreditRequest, PreparePeerPushCreditResponse, + ScopeType, TalerErrorDetail, TalerPreciseTimestamp, Transaction, @@ -630,6 +631,7 @@ export async function preparePeerPushCredit( ); if (existing) { + const currency = Amounts.currencyOf(existing.existingContractTerms.amount); return { amount: existing.existingContractTerms.amount, amountEffective: existing.existingPushInc.estimatedAmountEffective, @@ -640,6 +642,12 @@ export async function preparePeerPushCredit( tag: TransactionType.PeerPushCredit, peerPushCreditId: existing.existingPushInc.peerPushCreditId, }), + // FIXME: Shouldn't we place this in a tighter scope? + scopeInfo: { + type: ScopeType.Exchange, + currency, + url: existing.existingPushInc.exchangeBaseUrl, + }, exchangeBaseUrl: existing.existingPushInc.exchangeBaseUrl, ...getPeerCreditLimitInfo( exchange, @@ -742,12 +750,9 @@ export async function preparePeerPushCredit( }, ); - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.PeerPushCredit, - peerPushCreditId, - }); + const currency = Amounts.currencyOf(wi.withdrawalAmountRaw); - notifyTransition(wex, transactionId, transitionInfo); + notifyTransition(wex, ctx.transactionId, transitionInfo); return { amount: purseStatus.balance, @@ -755,8 +760,14 @@ export async function preparePeerPushCredit( amountRaw: purseStatus.balance, contractTerms: dec.contractTerms, peerPushCreditId, - transactionId, + transactionId: ctx.transactionId, exchangeBaseUrl, + // FIXME: Shouldn't we place this in a tighter scope? + scopeInfo: { + type: ScopeType.Exchange, + currency, + url: exchangeBaseUrl, + }, ...getPeerCreditLimitInfo(exchange, purseStatus.balance), }; } diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts @@ -26,6 +26,8 @@ import { InitiatePeerPushDebitResponse, Logger, RefreshReason, + ScopeInfo, + ScopeType, SelectedProspectiveCoin, TalerError, TalerErrorCode, @@ -452,12 +454,25 @@ async function internalCheckPeerPushDebit( req: CheckPeerPushDebitRequest, ): Promise<CheckPeerPushDebitResponse> { const instructedAmount = Amounts.parseOrThrow(req.amount); + const currency = instructedAmount.currency; logger.trace( `checking peer push debit for ${Amounts.stringify(instructedAmount)}`, ); + let restrictScope: ScopeInfo; + if (req.restrictScope) { + restrictScope = req.restrictScope; + } else if (req.exchangeBaseUrl) { + restrictScope = { + type: ScopeType.Exchange, + currency, + url: req.exchangeBaseUrl, + }; + } else { + throw Error("client must either specify exchangeBaseUrl or restrictScope"); + } const coinSelRes = await selectPeerCoins(wex, { instructedAmount, - restrictScope: req.restrictScope, + restrictScope, feesCoveredByCounterparty: false, }); let coins: SelectedProspectiveCoin[] | undefined = undefined; diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts @@ -167,6 +167,7 @@ import { fetchFreshExchange, getExchangePaytoUri, getExchangeWireDetailsInTx, + getPreferredExchangeForScope, getScopeForAllExchanges, handleStartExchangeWalletKyc, listExchanges, @@ -4044,9 +4045,21 @@ export async function internalGetWithdrawalDetailsForAmount( wex: WalletExecutionContext, req: GetWithdrawalDetailsForAmountRequest, ): Promise<WithdrawalDetailsForAmount> { + let exchangeBaseUrl: string | undefined; + if (req.exchangeBaseUrl) { + exchangeBaseUrl = req.exchangeBaseUrl; + } else if (req.restrictScope) { + exchangeBaseUrl = await getPreferredExchangeForScope( + wex, + req.restrictScope, + ); + } + if (!exchangeBaseUrl) { + throw Error("could not find exchange for withdrawal"); + } const wi = await getExchangeWithdrawalInfo( wex, - req.exchangeBaseUrl, + exchangeBaseUrl, Amounts.parseOrThrow(req.amount), req.restrictAge, ); @@ -4055,6 +4068,7 @@ export async function internalGetWithdrawalDetailsForAmount( numCoins += x.count; } const resp: WithdrawalDetailsForAmount = { + exchangeBaseUrl, amountRaw: req.amount, amountEffective: Amounts.stringify(wi.selectedDenoms.totalCoinValue), paytoUris: wi.exchangePaytoUris, diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/test.ts b/packages/taler-wallet-webextension/src/cta/Withdraw/test.ts @@ -29,8 +29,8 @@ import { TransactionIdStr, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; -import { expect } from "chai"; import * as tests from "@gnu-taler/web-util/testing"; +import { expect } from "chai"; import { createWalletApiMock } from "../../test-utils.js"; import { useComponentStateFromURI } from "./state.js"; @@ -186,6 +186,7 @@ describe("Withdraw CTA states", () => { type: ScopeType.Exchange, url: "http://asd", }, + exchangeBaseUrl: "http://asd", withdrawalAccountsList: [], ageRestrictionOptions: [], numCoins: 42, @@ -262,6 +263,7 @@ describe("Withdraw CTA states", () => { type: ScopeType.Exchange, url: "http://asd", }, + exchangeBaseUrl: "http://asd", tosAccepted: false, withdrawalAccountsList: [], ageRestrictionOptions: [],