diff options
Diffstat (limited to 'packages/taler-wallet-core')
33 files changed, 2984 insertions, 1296 deletions
diff --git a/packages/taler-wallet-core/package.json b/packages/taler-wallet-core/package.json index a4e1d11ca..9471902e0 100644 --- a/packages/taler-wallet-core/package.json +++ b/packages/taler-wallet-core/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/taler-wallet-core", - "version": "0.12.12", + "version": "0.13.10", "description": "", "engines": { "node": ">=0.18.0" diff --git a/packages/taler-wallet-core/src/backup/index.ts b/packages/taler-wallet-core/src/backup/index.ts index c5febd278..a9274f74b 100644 --- a/packages/taler-wallet-core/src/backup/index.ts +++ b/packages/taler-wallet-core/src/backup/index.ts @@ -325,7 +325,7 @@ async function runBackupCycleForProvider( } // const opId = TaskIdentifiers.forBackup(prov); // await scheduleRetryInTx(ws, tx, opId); - prov.currentPaymentProposalId = result.proposalId; + prov.currentPaymentTransactionId = result.transactionId; prov.shouldRetryFreshProposal = false; prov.state = { tag: BackupProviderStateTag.Retrying, diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts index 396efda1f..20eb71a7f 100644 --- a/packages/taler-wallet-core/src/balance.ts +++ b/packages/taler-wallet-core/src/balance.ts @@ -449,14 +449,13 @@ export async function getBalancesInsideTransaction( for (const [e, x] of Object.entries(perExchange)) { const currency = Amounts.currencyOf(dgRecord.amount); switch (dgRecord.operationStatus) { - case DepositOperationStatus.SuspendedKyc: - case DepositOperationStatus.PendingKyc: + case DepositOperationStatus.SuspendedAggregateKyc: + case DepositOperationStatus.PendingAggregateKyc: await balanceStore.setFlagOutgoingKyc(currency, e); } - switch (dgRecord.operationStatus) { - case DepositOperationStatus.SuspendedKyc: - case DepositOperationStatus.PendingKyc: + case DepositOperationStatus.SuspendedAggregateKyc: + case DepositOperationStatus.PendingAggregateKyc: case DepositOperationStatus.PendingTrack: case DepositOperationStatus.SuspendedAborting: case DepositOperationStatus.SuspendedDeposit: @@ -552,12 +551,12 @@ export interface PaymentBalanceDetails { balanceAgeAcceptable: AmountJson; /** - * Balance of type "merchant-acceptable" (see balance.ts for definition). + * Balance of type "receiver-acceptable" (see balance.ts for definition). */ balanceReceiverAcceptable: AmountJson; /** - * Balance of type "merchant-depositable" (see balance.ts for definition). + * Balance of type "receiver-depositable" (see balance.ts for definition). */ balanceReceiverDepositable: AmountJson; @@ -568,7 +567,11 @@ export interface PaymentBalanceDetails { */ balanceExchangeDepositable: AmountJson; - maxEffectiveSpendAmount: AmountJson; + /** + * Estimated maximum amount that the wallet could pay for, under the assumption + * that the merchant pays absolutely no fees. + */ + maxMerchantEffectiveDepositAmount: AmountJson; } export async function getPaymentBalanceDetails( @@ -610,7 +613,7 @@ export async function getPaymentBalanceDetailsInTx( balanceAgeAcceptable: Amounts.zeroOfCurrency(req.currency), balanceReceiverAcceptable: Amounts.zeroOfCurrency(req.currency), balanceReceiverDepositable: Amounts.zeroOfCurrency(req.currency), - maxEffectiveSpendAmount: Amounts.zeroOfCurrency(req.currency), + maxMerchantEffectiveDepositAmount: Amounts.zeroOfCurrency(req.currency), balanceExchangeDepositable: Amounts.zeroOfCurrency(req.currency), }; @@ -719,13 +722,13 @@ export async function getPaymentBalanceDetailsInTx( merchantExchangeAcceptable && merchantExchangeDepositable ) { - d.maxEffectiveSpendAmount = Amounts.add( - d.maxEffectiveSpendAmount, + d.maxMerchantEffectiveDepositAmount = Amounts.add( + d.maxMerchantEffectiveDepositAmount, Amounts.mult(ca.value, ca.freshCoinCount).amount, ).amount; - d.maxEffectiveSpendAmount = Amounts.sub( - d.maxEffectiveSpendAmount, + d.maxMerchantEffectiveDepositAmount = Amounts.sub( + d.maxMerchantEffectiveDepositAmount, Amounts.mult(denom.feeDeposit, ca.freshCoinCount).amount, ).amount; } diff --git a/packages/taler-wallet-core/src/coinSelection.test.ts b/packages/taler-wallet-core/src/coinSelection.test.ts index c7cb2857e..4984552f8 100644 --- a/packages/taler-wallet-core/src/coinSelection.test.ts +++ b/packages/taler-wallet-core/src/coinSelection.test.ts @@ -15,17 +15,21 @@ */ import { AbsoluteTime, + AmountJson, AmountString, Amounts, DenomKeyType, + DenominationPubKey, Duration, + TalerProtocolTimestamp, j2s, } from "@gnu-taler/taler-util"; import test from "ava"; import { - AvailableDenom, + AvailableCoinsOfDenom, CoinSelectionTally, emptyTallyForPeerPayment, + testing_getMaxDepositAmountForAvailableCoins, testing_selectGreedy, } from "./coinSelection.js"; @@ -42,7 +46,9 @@ const inThePast = AbsoluteTime.toProtocolTimestamp( test("p2p: should select the coin", (t) => { const instructedAmount = Amounts.parseOrThrow("LOCAL:2"); - const tally = emptyTallyForPeerPayment(instructedAmount); + const tally = emptyTallyForPeerPayment({ + instructedAmount, + }); t.log(`tally before: ${j2s(tally)}`); const coins = testing_selectGreedy( { @@ -76,7 +82,9 @@ test("p2p: should select the coin", (t) => { test("p2p: should select 3 coins", (t) => { const instructedAmount = Amounts.parseOrThrow("LOCAL:20"); - const tally = emptyTallyForPeerPayment(instructedAmount); + const tally = emptyTallyForPeerPayment({ + instructedAmount, + }); const coins = testing_selectGreedy( { wireFeesPerExchange: {}, @@ -108,7 +116,9 @@ test("p2p: should select 3 coins", (t) => { test("p2p: can't select since the instructed amount is too high", (t) => { const instructedAmount = Amounts.parseOrThrow("LOCAL:60"); - const tally = emptyTallyForPeerPayment(instructedAmount); + const tally = emptyTallyForPeerPayment({ + instructedAmount, + }); const coins = testing_selectGreedy( { wireFeesPerExchange: {}, @@ -138,6 +148,7 @@ test("pay: select one coin to pay with fee", (t) => { customerWireFees: zero, wireFeeCoveredForExchange: new Set<string>(), lastDepositFee: zero, + totalDepositFees: zero, } satisfies CoinSelectionTally; const coins = testing_selectGreedy( { @@ -180,7 +191,7 @@ function createCandidates( numAvailable: number; fromExchange: string; }[], -): AvailableDenom[] { +): AvailableCoinsOfDenom[] { return ar.map((r, idx) => { return { denomPub: { @@ -269,7 +280,9 @@ test("p2p: regression STATER", (t) => { }, ]; const instructedAmount = Amounts.parseOrThrow("STATER:1"); - const tally = emptyTallyForPeerPayment(instructedAmount); + const tally = emptyTallyForPeerPayment({ + instructedAmount, + }); const res = testing_selectGreedy( { wireFeesPerExchange: {}, @@ -279,3 +292,163 @@ test("p2p: regression STATER", (t) => { ); t.assert(!!res); }); + +function makeCurrencyHelper(currency: string) { + return (sx: TemplateStringsArray, ...vx: any[]) => { + const s = String.raw({ raw: sx }, ...vx); + return Amounts.parseOrThrow(`${currency}:${s}`); + }; +} + +type TestCoin = [AmountJson, number]; + +const kudos = makeCurrencyHelper("kudos"); + +function defaultFeeConfig( + value: AmountJson, + totalAvailable: number, +): AvailableCoinsOfDenom { + return { + denomPub: undefined as any as DenominationPubKey, + denomPubHash: "123", + feeDeposit: `KUDOS:0.01`, + feeRefresh: `KUDOS:0.01`, + feeRefund: `KUDOS:0.01`, + feeWithdraw: `KUDOS:0.01`, + exchangeBaseUrl: "2", + maxAge: 0, + numAvailable: totalAvailable, + stampExpireDeposit: TalerProtocolTimestamp.never(), + stampExpireLegal: TalerProtocolTimestamp.never(), + stampExpireWithdraw: TalerProtocolTimestamp.never(), + stampStart: TalerProtocolTimestamp.never(), + value: Amounts.stringify(value), + }; +} + +test("deposit max 35", (t) => { + const coinList: TestCoin[] = [ + [kudos`2`, 5], + [kudos`5`, 5], + ]; + const result = testing_getMaxDepositAmountForAvailableCoins( + { + currency: "KUDOS", + }, + { + coinAvailability: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + currentWireFeePerExchange: { + "2": kudos`0`, + }, + }, + ); + t.is(Amounts.stringifyValue(result.rawAmount), "34.9"); + t.is(Amounts.stringifyValue(result.effectiveAmount), "35"); +}); + +test("deposit max 35 with wirefee", (t) => { + const coinList: TestCoin[] = [ + [kudos`2`, 5], + [kudos`5`, 5], + ]; + const result = testing_getMaxDepositAmountForAvailableCoins( + { + currency: "KUDOS", + }, + { + coinAvailability: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + currentWireFeePerExchange: { + "2": kudos`1`, + }, + }, + ); + t.is(Amounts.stringifyValue(result.rawAmount), "33.9"); + t.is(Amounts.stringifyValue(result.effectiveAmount), "35"); +}); + +test("deposit max repeated denom", (t) => { + const coinList: TestCoin[] = [ + [kudos`2`, 1], + [kudos`2`, 1], + [kudos`5`, 1], + ]; + const result = testing_getMaxDepositAmountForAvailableCoins( + { + currency: "KUDOS", + }, + { + coinAvailability: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + currentWireFeePerExchange: { + "2": kudos`0`, + }, + }, + ); + t.is(Amounts.stringifyValue(result.rawAmount), "8.97"); + t.is(Amounts.stringifyValue(result.effectiveAmount), "9"); +}); + +test("demo: deposit max after withdraw raw 25", (t) => { + const coinList: TestCoin[] = [ + [kudos`0.1`, 8], + [kudos`1`, 0], + [kudos`2`, 2], + [kudos`5`, 0], + [kudos`10`, 2], + ]; + const result = testing_getMaxDepositAmountForAvailableCoins( + { + currency: "KUDOS", + }, + { + coinAvailability: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + currentWireFeePerExchange: { + "2": kudos`0.01`, + }, + }, + ); + t.is(Amounts.stringifyValue(result.effectiveAmount), "24.8"); + t.is(Amounts.stringifyValue(result.rawAmount), "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: deposit max after withdraw raw 13", (t) => { + const coinList: TestCoin[] = [ + [kudos`0.1`, 8], + [kudos`1`, 0], + [kudos`2`, 1], + [kudos`5`, 0], + [kudos`10`, 1], + ]; + const result = testing_getMaxDepositAmountForAvailableCoins( + { + currency: "KUDOS", + }, + { + coinAvailability: coinList.map(([v, t]) => defaultFeeConfig(v, t)), + currentWireFeePerExchange: { + "2": kudos`0.01`, + }, + }, + ); + t.is(Amounts.stringifyValue(result.effectiveAmount), "12.8"); + t.is(Amounts.stringifyValue(result.rawAmount), "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/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts index 51316a21f..de051d52e 100644 --- a/packages/taler-wallet-core/src/coinSelection.ts +++ b/packages/taler-wallet-core/src/coinSelection.ts @@ -26,25 +26,30 @@ import { GlobalIDB } from "@gnu-taler/idb-bridge"; import { AbsoluteTime, - AccountRestriction, AgeRestriction, AllowedAuditorInfo, AllowedExchangeInfo, AmountJson, Amounts, + checkAccountRestriction, checkDbInvariant, checkLogicInvariant, CoinStatus, DenominationInfo, ExchangeGlobalFees, ForcedCoinSel, - InternationalizedString, + GetMaxDepositAmountRequest, + GetMaxDepositAmountResponse, + GetMaxPeerPushDebitAmountRequest, + GetMaxPeerPushDebitAmountResponse, j2s, Logger, parsePaytoUri, PayCoinSelection, PaymentInsufficientBalanceDetails, ProspectivePayCoinSelection, + ScopeInfo, + ScopeType, SelectedCoin, SelectedProspectiveCoin, strcmp, @@ -54,6 +59,7 @@ import { getPaymentBalanceDetailsInTx } from "./balance.js"; import { getAutoRefreshExecuteThreshold } from "./common.js"; import { DenominationRecord, WalletDbReadOnlyTransaction } from "./db.js"; import { + checkExchangeInScopeTx, ExchangeWireDetails, getExchangeWireDetailsInTx, } from "./exchanges.js"; @@ -86,6 +92,8 @@ export interface CoinSelectionTally { customerDepositFees: AmountJson; + totalDepositFees: AmountJson; + customerWireFees: AmountJson; wireFeeCoveredForExchange: Set<string>; @@ -152,6 +160,10 @@ function tallyFees( dfRemaining, ).amount; tally.lastDepositFee = feeDeposit; + tally.totalDepositFees = Amounts.add( + tally.totalDepositFees, + feeDeposit, + ).amount; } export type SelectPayCoinsResult = @@ -180,19 +192,32 @@ async function internalSelectPayCoins( | { sel: SelResult; coinRes: SelectedCoin[]; tally: CoinSelectionTally } | undefined > { + let restrictWireMethod; + if (req.depositPaytoUri) { + const parsedPayto = parsePaytoUri(req.depositPaytoUri); + if (!parsedPayto) { + throw Error("invalid payto URI"); + } + restrictWireMethod = parsedPayto.targetType; + if (restrictWireMethod !== req.restrictWireMethod) { + logger.warn(`conflicting payto URI and wire method restriction`); + } + } else { + restrictWireMethod = req.restrictWireMethod; + } + const { contractTermsAmount, depositFeeLimit } = req; - const [candidateDenoms, wireFeesPerExchange] = await selectPayCandidates( - wex, - tx, - { - restrictExchanges: req.restrictExchanges, - instructedAmount: req.contractTermsAmount, - restrictWireMethod: req.restrictWireMethod, - depositPaytoUri: req.depositPaytoUri, - requiredMinimumAge: req.requiredMinimumAge, - includePendingCoins, - }, - ); + const candidateRes = await selectPayCandidates(wex, tx, { + currency: Amounts.currencyOf(req.contractTermsAmount), + restrictExchanges: req.restrictExchanges, + restrictWireMethod: req.restrictWireMethod, + depositPaytoUri: req.depositPaytoUri, + requiredMinimumAge: req.requiredMinimumAge, + includePendingCoins, + }); + + const wireFeesPerExchange = candidateRes.currentWireFeePerExchange; + const candidateDenoms = candidateRes.coinAvailability; if (logger.shouldLogTrace()) { logger.trace( @@ -210,6 +235,7 @@ async function internalSelectPayCoins( amountDepositFeeLimitRemaining: depositFeeLimit, customerDepositFees: Amounts.zeroOfCurrency(currency), customerWireFees: Amounts.zeroOfCurrency(currency), + totalDepositFees: Amounts.zeroOfCurrency(currency), wireFeeCoveredForExchange: new Set(), lastDepositFee: Amounts.zeroOfCurrency(currency), }; @@ -452,6 +478,7 @@ async function assembleSelectPayCoinsSuccessResult( coins: coinRes, customerDepositFees: Amounts.stringify(tally.customerDepositFees), customerWireFees: Amounts.stringify(tally.customerWireFees), + totalDepositFees: Amounts.stringify(tally.totalDepositFees), }; } @@ -493,12 +520,16 @@ export async function reportInsufficientBalanceDetails( let missingGlobalFees = false; const exchWire = await getExchangeWireDetailsInTx(tx, exch.baseUrl); if (!exchWire) { + // No wire details about the exchange known, skip! + continue; + } + const globalFees = getGlobalFees(exchWire); + if (!globalFees) { missingGlobalFees = true; - } else { - const globalFees = getGlobalFees(exchWire); - if (!globalFees) { - missingGlobalFees = true; - } + } + if (exchWire.currency !== Amounts.currencyOf(req.instructedAmount)) { + // Do not report anything for an exchange with a different currency. + continue; } const exchDet = await getPaymentBalanceDetailsInTx(wex, tx, { restrictExchanges: { @@ -510,7 +541,7 @@ export async function reportInsufficientBalanceDetails( ], auditors: [], }, - restrictWireMethods: req.wireMethod ? [req.wireMethod] : [], + restrictWireMethods: req.wireMethod ? [req.wireMethod] : undefined, currency: Amounts.currencyOf(req.instructedAmount), minAge: req.requiredMinimumAge ?? 0, depositPaytoUri: req.depositPaytoUri, @@ -529,7 +560,7 @@ export async function reportInsufficientBalanceDetails( exchDet.balanceReceiverDepositable, ), maxEffectiveSpendAmount: Amounts.stringify( - exchDet.maxEffectiveSpendAmount, + exchDet.maxMerchantEffectiveDepositAmount, ), missingGlobalFees, }; @@ -549,7 +580,9 @@ export async function reportInsufficientBalanceDetails( balanceReceiverDepositable: Amounts.stringify( details.balanceReceiverDepositable, ), - maxEffectiveSpendAmount: Amounts.stringify(details.maxEffectiveSpendAmount), + maxEffectiveSpendAmount: Amounts.stringify( + details.maxMerchantEffectiveDepositAmount, + ), perExchange, }; } @@ -590,7 +623,7 @@ export interface SelectGreedyRequest { function selectGreedy( req: SelectGreedyRequest, - candidateDenoms: AvailableDenom[], + candidateDenoms: AvailableCoinsOfDenom[], tally: CoinSelectionTally, ): SelResult | undefined { const selectedDenom: SelResult = {}; @@ -653,7 +686,7 @@ function selectGreedy( function selectForced( req: SelectPayCoinRequestNg, - candidateDenoms: AvailableDenom[], + candidateDenoms: AvailableCoinsOfDenom[], ): SelResult | undefined { const selectedDenom: SelResult = {}; @@ -695,31 +728,6 @@ function selectForced( return selectedDenom; } -export function checkAccountRestriction( - paytoUri: string, - restrictions: AccountRestriction[], -): { ok: boolean; hint?: string; hintI18n?: InternationalizedString } { - for (const myRestriction of restrictions) { - switch (myRestriction.type) { - case "deny": - return { ok: false }; - case "regex": { - const regex = new RegExp(myRestriction.payto_regex); - if (!regex.test(paytoUri)) { - return { - ok: false, - hint: myRestriction.human_hint, - hintI18n: myRestriction.human_hint_i18n, - }; - } - } - } - } - return { - ok: true, - }; -} - export interface SelectPayCoinRequestNg { restrictExchanges: ExchangeRestrictionSpec | undefined; restrictWireMethod: string; @@ -739,7 +747,7 @@ export interface SelectPayCoinRequestNg { depositPaytoUri?: string; } -export type AvailableDenom = DenominationInfo & { +export type AvailableCoinsOfDenom = DenominationInfo & { maxAge: number; numAvailable: number; }; @@ -820,7 +828,7 @@ function checkExchangeAccepted( } interface SelectPayCandidatesRequest { - instructedAmount: AmountJson; + currency: string; restrictWireMethod: string | undefined; depositPaytoUri?: string; restrictExchanges: ExchangeRestrictionSpec | undefined; @@ -834,18 +842,23 @@ interface SelectPayCandidatesRequest { includePendingCoins: boolean; } +export interface PayCoinCandidates { + coinAvailability: AvailableCoinsOfDenom[]; + currentWireFeePerExchange: Record<string, AmountJson>; +} + async function selectPayCandidates( wex: WalletExecutionContext, tx: WalletDbReadOnlyTransaction< ["exchanges", "coinAvailability", "exchangeDetails", "denominations"] >, req: SelectPayCandidatesRequest, -): Promise<[AvailableDenom[], Record<string, AmountJson>]> { +): Promise<PayCoinCandidates> { // FIXME: Use the existing helper (from balance.ts) to // get acceptable exchanges. logger.shouldLogTrace() && logger.trace(`selecting available coin candidates for ${j2s(req)}`); - const denoms: AvailableDenom[] = []; + const denoms: AvailableCoinsOfDenom[] = []; const exchanges = await tx.exchanges.iter().toArray(); const wfPerExchange: Record<string, AmountJson> = {}; for (const exchange of exchanges) { @@ -854,7 +867,7 @@ async function selectPayCandidates( exchange.baseUrl, ); // 1. exchange has same currency - if (exchangeDetails?.currency !== req.instructedAmount.currency) { + if (exchangeDetails?.currency !== req.currency) { logger.shouldLogTrace() && logger.trace(`skipping ${exchange.baseUrl} due to currency mismatch`); continue; @@ -961,7 +974,10 @@ async function selectPayCandidates( Amounts.cmp(o1.feeDeposit, o2.feeDeposit) || strcmp(o1.denomPubHash, o2.denomPubHash), ); - return [denoms, wfPerExchange]; + return { + coinAvailability: denoms, + currentWireFeePerExchange: wfPerExchange, + }; } export interface PeerCoinSelectionDetails { @@ -975,7 +991,9 @@ export interface PeerCoinSelectionDetails { /** * How much of the deposit fees is the customer paying? */ - depositFees: AmountJson; + customerDepositFees: AmountJson; + + totalDepositFees: AmountJson; maxExpirationDate: TalerProtocolTimestamp; } @@ -988,7 +1006,9 @@ export interface ProspectivePeerCoinSelectionDetails { /** * How much of the deposit fees is the customer paying? */ - depositFees: AmountJson; + customerDepositFees: AmountJson; + + totalDepositFees: AmountJson; maxExpirationDate: TalerProtocolTimestamp; } @@ -1006,6 +1026,19 @@ export interface PeerCoinSelectionRequest { instructedAmount: AmountJson; /** + * Are deposit fees covered by the counterparty? + * + * Defaults to false. + */ + feesCoveredByCounterparty?: boolean; + + /** + * Restrict the scope of funds that can be spent via the given + * scope info. + */ + restrictScope?: ScopeInfo; + + /** * Instruct the coin selection to repair this coin * selection instead of selecting completely new coins. */ @@ -1045,17 +1078,21 @@ export async function computeCoinSelMaxExpirationDate( } export function emptyTallyForPeerPayment( - instructedAmount: AmountJson, + req: PeerCoinSelectionRequest, ): CoinSelectionTally { + const instructedAmount = req.instructedAmount; const currency = instructedAmount.currency; const zero = Amounts.zeroOfCurrency(currency); return { amountPayRemaining: instructedAmount, customerDepositFees: zero, lastDepositFee: zero, - amountDepositFeeLimitRemaining: zero, + amountDepositFeeLimitRemaining: req.feesCoveredByCounterparty + ? instructedAmount + : zero, customerWireFees: zero, wireFeeCoveredForExchange: new Set(), + totalDepositFees: zero, }; } @@ -1098,7 +1135,7 @@ async function internalSelectPeerCoins( | undefined > { const candidatesRes = await selectPayCandidates(wex, tx, { - instructedAmount: req.instructedAmount, + currency: Amounts.currencyOf(req.instructedAmount), restrictExchanges: { auditors: [], exchanges: [ @@ -1111,11 +1148,11 @@ async function internalSelectPeerCoins( restrictWireMethod: undefined, includePendingCoins, }); - const candidates = candidatesRes[0]; + const candidates = candidatesRes.coinAvailability; if (logger.shouldLogTrace()) { logger.trace(`peer payment candidate coins: ${j2s(candidates)}`); } - const tally = emptyTallyForPeerPayment(req.instructedAmount); + const tally = emptyTallyForPeerPayment(req); const resCoins: SelectedCoin[] = []; await maybeRepairCoinSelection(wex, tx, req.repair ?? [], resCoins, tally, { @@ -1157,6 +1194,8 @@ export async function selectPeerCoinsInTx( "denominations", "refreshGroups", "exchangeDetails", + "globalCurrencyExchanges", + "globalCurrencyAuditors", ] >, req: PeerCoinSelectionRequest, @@ -1178,6 +1217,19 @@ export async function selectPeerCoinsInTx( if (!exchWire) { continue; } + const isInScope = req.restrictScope + ? await checkExchangeInScopeTx(wex, tx, exch.baseUrl, req.restrictScope) + : true; + if (!isInScope) { + continue; + } + if ( + req.restrictScope && + req.restrictScope.type === ScopeType.Exchange && + req.restrictScope.url !== exch.baseUrl + ) { + continue; + } const globalFees = getGlobalFees(exchWire); if (!globalFees) { continue; @@ -1215,7 +1267,8 @@ export async function selectPeerCoinsInTx( type: "prospective", result: { prospectiveCoins, - depositFees: prospectiveAvRes.tally.customerDepositFees, + customerDepositFees: prospectiveAvRes.tally.customerDepositFees, + totalDepositFees: prospectiveAvRes.tally.totalDepositFees, exchangeBaseUrl: exch.baseUrl, maxExpirationDate, }, @@ -1239,7 +1292,8 @@ export async function selectPeerCoinsInTx( type: "success", result: { coins: r.coins, - depositFees: Amounts.parseOrThrow(r.customerDepositFees), + customerDepositFees: Amounts.parseOrThrow(r.customerDepositFees), + totalDepositFees: Amounts.parseOrThrow(r.totalDepositFees), exchangeBaseUrl: exch.baseUrl, maxExpirationDate, }, @@ -1277,6 +1331,8 @@ export async function selectPeerCoins( "denominations", "refreshGroups", "exchangeDetails", + "globalCurrencyExchanges", + "globalCurrencyAuditors", ], }, async (tx): Promise<SelectPeerCoinsResult> => { @@ -1284,3 +1340,200 @@ export async function selectPeerCoins( }, ); } + +function getMaxDepositAmountForAvailableCoins( + req: GetMaxDepositAmountRequest, + candidateRes: PayCoinCandidates, +): GetMaxDepositAmountResponse { + const wireFeeCoveredForExchange = new Set<string>(); + + let amountEffective = Amounts.zeroOfCurrency(req.currency); + let fees = Amounts.zeroOfCurrency(req.currency); + + for (const cc of candidateRes.coinAvailability) { + if (!wireFeeCoveredForExchange.has(cc.exchangeBaseUrl)) { + const wireFee = + candidateRes.currentWireFeePerExchange[cc.exchangeBaseUrl]; + // Wire fee can be null if max deposit amount is computed + // without restricting the wire method. + if (wireFee != null) { + fees = Amounts.add(fees, wireFee).amount; + } + wireFeeCoveredForExchange.add(cc.exchangeBaseUrl); + } + + amountEffective = Amounts.add( + amountEffective, + Amounts.mult(cc.value, cc.numAvailable).amount, + ).amount; + + fees = Amounts.add( + fees, + Amounts.mult(cc.feeDeposit, cc.numAvailable).amount, + ).amount; + } + + return { + effectiveAmount: Amounts.stringify(amountEffective), + rawAmount: Amounts.stringify(Amounts.sub(amountEffective, fees).amount), + }; +} + +/** + * Only used for unit testing getMaxDepositAmountForAvailableCoins. + */ +export const testing_getMaxDepositAmountForAvailableCoins = + getMaxDepositAmountForAvailableCoins; + +export async function getMaxDepositAmount( + wex: WalletExecutionContext, + req: GetMaxDepositAmountRequest, +): Promise<GetMaxDepositAmountResponse> { + logger.trace(`getting max deposit amount for: ${j2s(req)}`); + return await wex.db.runReadOnlyTx( + { + storeNames: [ + "exchanges", + "coinAvailability", + "denominations", + "exchangeDetails", + ], + }, + async (tx): Promise<GetMaxDepositAmountResponse> => { + let restrictWireMethod: string | undefined = undefined; + if (req.depositPaytoUri) { + const p = parsePaytoUri(req.depositPaytoUri); + if (!p) { + throw Error("invalid payto URI"); + } + restrictWireMethod = p.targetType; + } + const candidateRes = await selectPayCandidates(wex, tx, { + currency: req.currency, + restrictExchanges: undefined, + restrictWireMethod, + depositPaytoUri: req.depositPaytoUri, + requiredMinimumAge: undefined, + includePendingCoins: true, + }); + return getMaxDepositAmountForAvailableCoins(req, candidateRes); + }, + ); +} + +function getMaxPeerPushDebitAmountForAvailableCoins( + req: GetMaxDepositAmountRequest, + exchangeBaseUrl: string, + candidateRes: PayCoinCandidates, +): GetMaxPeerPushDebitAmountResponse { + let amountEffective = Amounts.zeroOfCurrency(req.currency); + let fees = Amounts.zeroOfCurrency(req.currency); + + for (const cc of candidateRes.coinAvailability) { + amountEffective = Amounts.add( + amountEffective, + Amounts.mult(cc.value, cc.numAvailable).amount, + ).amount; + + fees = Amounts.add( + fees, + Amounts.mult(cc.feeDeposit, cc.numAvailable).amount, + ).amount; + } + + return { + exchangeBaseUrl, + effectiveAmount: Amounts.stringify(amountEffective), + rawAmount: Amounts.stringify(Amounts.sub(amountEffective, fees).amount), + }; +} + +export async function getMaxPeerPushDebitAmount( + wex: WalletExecutionContext, + req: GetMaxPeerPushDebitAmountRequest, +): Promise<GetMaxPeerPushDebitAmountResponse> { + logger.trace(`getting max deposit amount for: ${j2s(req)}`); + + return await wex.db.runReadOnlyTx( + { + storeNames: [ + "exchanges", + "coinAvailability", + "denominations", + "exchangeDetails", + "globalCurrencyExchanges", + "globalCurrencyAuditors", + ], + }, + async (tx): Promise<GetMaxPeerPushDebitAmountResponse> => { + let result: GetMaxDepositAmountResponse | undefined = undefined; + const currency = req.currency; + const exchanges = await tx.exchanges.iter().toArray(); + for (const exch of exchanges) { + if (exch.detailsPointer?.currency !== currency) { + continue; + } + const exchWire = await getExchangeWireDetailsInTx(tx, exch.baseUrl); + if (!exchWire) { + continue; + } + const isInScope = req.restrictScope + ? await checkExchangeInScopeTx( + wex, + tx, + exch.baseUrl, + req.restrictScope, + ) + : true; + if (!isInScope) { + continue; + } + if ( + req.restrictScope && + req.restrictScope.type === ScopeType.Exchange && + req.restrictScope.url !== exch.baseUrl + ) { + continue; + } + const globalFees = getGlobalFees(exchWire); + if (!globalFees) { + continue; + } + + const candidatesRes = await selectPayCandidates(wex, tx, { + currency, + restrictExchanges: { + auditors: [], + exchanges: [ + { + exchangeBaseUrl: exchWire.exchangeBaseUrl, + exchangePub: exchWire.masterPublicKey, + }, + ], + }, + restrictWireMethod: undefined, + includePendingCoins: true, + }); + + const myExchangeRes = getMaxPeerPushDebitAmountForAvailableCoins( + req, + exchWire.exchangeBaseUrl, + candidatesRes, + ); + + if (!result) { + result = myExchangeRes; + } else if (Amounts.cmp(result.rawAmount, myExchangeRes.rawAmount) < 0) { + result = myExchangeRes; + } + } + if (!result) { + return { + effectiveAmount: Amounts.stringify(Amounts.zeroOfCurrency(currency)), + rawAmount: Amounts.stringify(Amounts.zeroOfCurrency(currency)), + }; + } + return result; + }, + ); +} diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts index 02dca20e8..d9438d19c 100644 --- a/packages/taler-wallet-core/src/common.ts +++ b/packages/taler-wallet-core/src/common.ts @@ -799,10 +799,10 @@ export const TransitionResult = { export interface TransactionContext { get taskId(): TaskIdStr | undefined; get transactionId(): TransactionIdStr; - abortTransaction(): Promise<void>; + abortTransaction(reason?: TalerErrorDetail): Promise<void>; suspendTransaction(): Promise<void>; resumeTransaction(): Promise<void>; - failTransaction(): Promise<void>; + failTransaction(reason?: TalerErrorDetail): Promise<void>; deleteTransaction(): Promise<void>; lookupFullTransaction( tx: WalletDbAllStoresReadOnlyTransaction, diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts index 69081d234..c1a4ca455 100644 --- a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts +++ b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts @@ -105,6 +105,8 @@ import { EncryptContractResponse, SignCoinHistoryRequest, SignCoinHistoryResponse, + SignContractTermsHashRequest, + SignContractTermsHashResponse, SignDeletePurseRequest, SignDeletePurseResponse, SignPurseMergeRequest, @@ -170,7 +172,7 @@ export interface TalerCryptoInterface { req: ContractTermsValidationRequest, ): Promise<ValidationResult>; - createEddsaKeypair(req: {}): Promise<EddsaKeypair>; + createEddsaKeypair(req: {}): Promise<EddsaKeyPairStrings>; eddsaGetPublic(req: EddsaGetPublicRequest): Promise<EddsaGetPublicResponse>; @@ -265,6 +267,10 @@ export interface TalerCryptoInterface { signWalletKycAuth( req: SignWalletKycAuthRequest, ): Promise<SignWalletKycAuthResponse>; + + signContractTermsHash( + req: SignContractTermsHashRequest, + ): Promise<SignContractTermsHashResponse>; } /** @@ -333,10 +339,12 @@ export const nullCrypto: TalerCryptoInterface = { ): Promise<ValidationResult> { throw new Error("Function not implemented."); }, - createEddsaKeypair: function (req: unknown): Promise<EddsaKeypair> { + createEddsaKeypair: function (req: unknown): Promise<EddsaKeyPairStrings> { throw new Error("Function not implemented."); }, - eddsaGetPublic: function (req: EddsaGetPublicRequest): Promise<EddsaKeypair> { + eddsaGetPublic: function ( + req: EddsaGetPublicRequest, + ): Promise<EddsaKeyPairStrings> { throw new Error("Function not implemented."); }, unblindDenominationSignature: function ( @@ -469,6 +477,11 @@ export const nullCrypto: TalerCryptoInterface = { ): Promise<SignWalletKycAuthResponse> { throw new Error("Function not implemented."); }, + signContractTermsHash: function ( + req: SignContractTermsHashRequest, + ): Promise<SignContractTermsHashResponse> { + throw new Error("Function not implemented."); + }, }; export type WithArg<X> = X extends (req: infer T) => infer R @@ -519,6 +532,7 @@ export interface SpendCoinDetails { coinPub: string; coinPriv: string; contribution: AmountString; + feeDeposit: AmountString; denomPubHash: string; denomSig: UnblindedSignature; ageCommitmentProof: AgeCommitmentProof | undefined; @@ -600,7 +614,7 @@ export interface WireAccountValidationRequest { creditRestrictions?: any[]; } -export interface EddsaKeypair { +export interface EddsaKeyPairStrings { priv: string; pub: string; } @@ -1081,7 +1095,9 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { /** * Create a new EdDSA key pair. */ - async createEddsaKeypair(tci: TalerCryptoInterfaceR): Promise<EddsaKeypair> { + async createEddsaKeypair( + tci: TalerCryptoInterfaceR, + ): Promise<EddsaKeyPairStrings> { const eddsaPriv = encodeCrock(getRandomBytes(32)); const eddsaPubRes = await tci.eddsaGetPublic(tci, { priv: eddsaPriv, @@ -1095,7 +1111,7 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { async eddsaGetPublic( tci: TalerCryptoInterfaceR, req: EddsaGetPublicRequest, - ): Promise<EddsaKeypair> { + ): Promise<EddsaKeyPairStrings> { return { priv: req.priv, pub: encodeCrock(eddsaGetPublic(decodeCrock(req.priv))), @@ -1815,6 +1831,21 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { sig: sigResp.sig, }; }, + async signContractTermsHash( + tci: TalerCryptoInterfaceR, + req: SignContractTermsHashRequest, + ): Promise<SignContractTermsHashResponse> { + const sigData = buildSigPS(TalerSignaturePurpose.MERCHANT_CONTRACT) + .put(decodeCrock(req.contractTermsHash)) + .build(); + const sigRes = await tci.eddsaSign(tci, { + msg: encodeCrock(sigData), + priv: req.merchantPriv, + }); + return { + sig: sigRes.sig, + }; + }, }; export interface EddsaSignRequest { diff --git a/packages/taler-wallet-core/src/crypto/cryptoTypes.ts b/packages/taler-wallet-core/src/crypto/cryptoTypes.ts index f6d17cb51..d866025f2 100644 --- a/packages/taler-wallet-core/src/crypto/cryptoTypes.ts +++ b/packages/taler-wallet-core/src/crypto/cryptoTypes.ts @@ -33,9 +33,11 @@ import { AmountString, CoinEnvelope, DenominationPubKey, + EddsaPrivateKeyString, EddsaPublicKeyString, EddsaSignatureString, ExchangeProtocolVersion, + HashCodeString, RefreshPlanchetInfo, TalerProtocolTimestamp, UnblindedSignature, @@ -244,6 +246,15 @@ export interface SignWalletKycAuthResponse { sig: string; } +export interface SignContractTermsHashRequest { + merchantPriv: EddsaPrivateKeyString; + contractTermsHash: HashCodeString; +} + +export interface SignContractTermsHashResponse { + sig: string; +} + export interface SignPurseMergeRequest { mergeTimestamp: TalerProtocolTimestamp; diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index c48a43f11..bcb9a284f 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -29,6 +29,7 @@ import { } from "@gnu-taler/idb-bridge"; import { AbsoluteTime, + AccountLimit, AgeCommitmentProof, AmountString, Amounts, @@ -61,6 +62,7 @@ import { UnblindedSignature, WireInfo, WithdrawalExchangeAccountDetails, + ZeroLimitedOperation, codecForAny, j2s, stringifyScopeInfo, @@ -273,6 +275,9 @@ export const OPERATION_STATUS_NONFINAL_FIRST = 0x0100_0000; */ export const OPERATION_STATUS_NONFINAL_LAST = 0x0210_ffff; +export const OPERATION_STATUS_DONE_FIRST = 0x0500_0000; +export const OPERATION_STATUS_DONE_LAST = 0x0500_ffff; + /** * Status of a withdrawal. */ @@ -414,6 +419,8 @@ export interface ReserveBankInfo { currency: string | undefined; externalConfirmation?: boolean; + + senderWire?: string; } /** @@ -624,6 +631,10 @@ export interface ExchangeDetailsRecord { ageMask?: number; walletBalanceLimits?: AmountString[]; + + hardLimits?: AccountLimit[]; + + zeroLimits?: ZeroLimitedOperation[]; } export interface ExchangeDetailsPointer { @@ -1062,6 +1073,8 @@ export interface RefreshGroupRecord { timestampCreated: DbPreciseTimestamp; + failReason?: TalerErrorDetail; + /** * Timestamp when the refresh session finished. */ @@ -1279,6 +1292,9 @@ export interface PurchaseRecord { purchaseStatus: PurchaseStatus; + abortReason?: TalerErrorDetail; + failReason?: TalerErrorDetail; + /** * Private key for the nonce. */ @@ -1414,6 +1430,7 @@ export const enum WithdrawalRecordType { export interface WgInfoBankIntegrated { withdrawalType: WithdrawalRecordType.BankIntegrated; + /** * Extra state for when this is a withdrawal involving * a Taler-integrated bank. @@ -1466,11 +1483,6 @@ export type WgInfo = export type KycUserType = "individual" | "business"; -export interface KycPendingInfo { - paytoHash: string; - requirementRow: number; -} - /** * Group of withdrawal operations that need to be executed. * (Either for a normal withdrawal or from a reward.) @@ -1486,9 +1498,7 @@ export interface WithdrawalGroupRecord { wgInfo: WgInfo; - kycPending?: KycPendingInfo; - - kycUrl?: string; + kycPaytoHash?: string; kycAccessToken?: string; @@ -1533,14 +1543,6 @@ export interface WithdrawalGroupRecord { status: WithdrawalGroupStatus; /** - * Wire information (as payto URI) for the bank account that - * transferred funds for this reserve. - * - * FIXME: Doesn't this belong to the bankAccounts object store? - */ - senderWire?: string; - - /** * Restrict withdrawals from this reserve to this age. */ restrictAge?: number; @@ -1588,6 +1590,9 @@ export interface WithdrawalGroupRecord { * FIXME: Should this not also include a timestamp for more logical merging? */ denomSelUid: string; + + abortReason?: TalerErrorDetail; + failReason?: TalerErrorDetail; } export interface BankWithdrawUriRecord { @@ -1710,7 +1715,7 @@ export interface BackupProviderRecord { * * FIXME: Make this part of a proper BackupProviderState? */ - currentPaymentProposalId?: string; + currentPaymentTransactionId?: string; shouldRetryFreshProposal: boolean; @@ -1732,20 +1737,30 @@ export interface BackupProviderRecord { export enum DepositOperationStatus { PendingDeposit = 0x0100_0000, + SuspendedDeposit = 0x0110_0000, + PendingTrack = 0x0100_0001, - PendingKyc = 0x0100_0002, + SuspendedTrack = 0x0110_0001, - Aborting = 0x0103_0000, + PendingAggregateKyc = 0x0100_0002, + SuspendedAggregateKyc = 0x0110_0002, - SuspendedDeposit = 0x0110_0000, - SuspendedTrack = 0x0110_0001, - SuspendedKyc = 0x0110_0002, + PendingDepositKyc = 0x0100_0003, + SuspendedDepositKyc = 0x0110_0003, + + PendingDepositKycAuth = 0x0100_0005, + SuspendedDepositKycAuth = 0x0110_0005, + Aborting = 0x0103_0000, SuspendedAborting = 0x0113_0000, Finished = 0x0500_0000, - Failed = 0x0501_0000, - Aborted = 0x0503_0000, + + FailedDeposit = 0x0501_0000, + + FailedTrack = 0x0501_0001, + + AbortedDeposit = 0x0503_0000, } export interface DepositTrackingInfo { @@ -1829,6 +1844,9 @@ export interface DepositGroupRecord { */ abortRefreshGroupId?: string; + abortReason?: TalerErrorDetail; + failReason?: TalerErrorDetail; + kycInfo?: DepositKycInfo; // FIXME: Do we need this and should it be in this object store? @@ -1838,8 +1856,7 @@ export interface DepositGroupRecord { } export interface DepositKycInfo { - kycUrl: string; - requirementRow: number; + accessToken?: string; paytoHash: string; exchangeBaseUrl: string; } @@ -1894,10 +1911,22 @@ export interface PeerPushDebitRecord { exchangeBaseUrl: string; /** + * Restricted scope for this transaction. + * + * Relevant for coin reselection. + */ + restrictScope?: ScopeInfo; + + /** * Instructed amount. */ amount: AmountString; + /** + * Effective amount. + * + * (Called totalCost for historical reasons.) + */ totalCost: AmountString; coinSel?: DbPeerPushPaymentCoinSelection; @@ -1939,6 +1968,9 @@ export interface PeerPushDebitRecord { abortRefreshGroupId?: string; + abortReason?: TalerErrorDetail; + failReason?: TalerErrorDetail; + /** * Status of the peer push payment initiation. */ @@ -2029,12 +2061,13 @@ export interface PeerPullCreditRecord { */ status: PeerPullPaymentCreditStatus; - kycInfo?: KycPendingInfo; - - kycUrl?: string; + kycPaytoHash?: string; kycAccessToken?: string; + abortReason?: TalerErrorDetail; + failReason?: TalerErrorDetail; + withdrawalGroupId: string | undefined; } @@ -2096,6 +2129,9 @@ export interface PeerPushPaymentIncomingRecord { */ status: PeerPushCreditStatus; + abortReason?: TalerErrorDetail; + failReason?: TalerErrorDetail; + /** * Associated withdrawal group. */ @@ -2109,9 +2145,7 @@ export interface PeerPushPaymentIncomingRecord { */ currency: string | undefined; - kycInfo?: KycPendingInfo; - - kycUrl?: string; + kycPaytoHash?: string; kycAccessToken?: string; } @@ -2174,6 +2208,9 @@ export interface PeerPullPaymentIncomingRecord { abortRefreshGroupId?: string; + abortReason?: TalerErrorDetail; + failReason?: TalerErrorDetail; + coinSel?: PeerPullPaymentCoinSelection; } diff --git a/packages/taler-wallet-core/src/dbless.ts b/packages/taler-wallet-core/src/dbless.ts index bc0b6e428..f0a43084d 100644 --- a/packages/taler-wallet-core/src/dbless.ts +++ b/packages/taler-wallet-core/src/dbless.ts @@ -212,7 +212,8 @@ export async function depositCoin(args: { coin: CoinInfo; amount: AmountString; depositPayto?: string; - merchantPub?: string; + merchantPub: string; + merchantPriv: string; contractTermsHash?: string; // 16 bytes, crockford encoded wireSalt?: string; @@ -243,6 +244,10 @@ export async function depositCoin(args: { refundDeadline: refundDeadline, wireInfoHash: hashWire(depositPayto, wireSalt), }); + const merchantContractSigResp = await cryptoApi.signContractTermsHash({ + contractTermsHash, + merchantPriv: merchantPub, + }); const requestBody: ExchangeBatchDepositRequest = { coins: [ { @@ -253,6 +258,7 @@ export async function depositCoin(args: { ub_sig: dp.ub_sig, }, ], + merchant_sig: merchantContractSigResp.sig, merchant_payto_uri: depositPayto, wire_salt: wireSalt, h_contract_terms: contractTermsHash, diff --git a/packages/taler-wallet-core/src/denomSelection.ts b/packages/taler-wallet-core/src/denomSelection.ts index ecc1fa881..9e62857bf 100644 --- a/packages/taler-wallet-core/src/denomSelection.ts +++ b/packages/taler-wallet-core/src/denomSelection.ts @@ -123,9 +123,6 @@ export function selectWithdrawalDenominations( selectedDenoms, totalCoinValue: Amounts.stringify(totalCoinValue), totalWithdrawCost: Amounts.stringify(totalWithdrawCost), - earliestDepositExpiration: AbsoluteTime.toProtocolTimestamp( - earliestDepositExpiration, - ), hasDenomWithAgeRestriction, }; } @@ -191,9 +188,6 @@ export function selectForcedWithdrawalDenominations( selectedDenoms, totalCoinValue: Amounts.stringify(totalCoinValue), totalWithdrawCost: Amounts.stringify(totalWithdrawCost), - earliestDepositExpiration: AbsoluteTime.toProtocolTimestamp( - earliestDepositExpiration, - ), hasDenomWithAgeRestriction, }; } diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts index e4b378f44..d35b24f32 100644 --- a/packages/taler-wallet-core/src/deposits.ts +++ b/packages/taler-wallet-core/src/deposits.ts @@ -27,25 +27,26 @@ import { Amounts, BatchDepositRequestCoin, CancellationToken, + CheckDepositRequest, + CheckDepositResponse, CoinRefreshRequest, CreateDepositGroupRequest, CreateDepositGroupResponse, DepositGroupFees, DepositTransactionTrackingState, Duration, + Exchange, ExchangeBatchDepositRequest, - ExchangeHandle, ExchangeRefundRequest, HttpStatusCode, + KycAuthTransferInfo, Logger, MerchantContractTerms, - NotificationType, - PrepareDepositRequest, - PrepareDepositResponse, RefreshReason, SelectedProspectiveCoin, TalerError, TalerErrorCode, + TalerErrorDetail, TalerPreciseTimestamp, TalerProtocolTimestamp, TrackTransaction, @@ -61,7 +62,9 @@ import { canonicalJson, checkDbInvariant, checkLogicInvariant, + codecForAccountKycStatus, codecForBatchDepositSuccess, + codecForLegitimizationNeededResponse, codecForTackTransactionAccepted, codecForTackTransactionWired, encodeCrock, @@ -72,7 +75,12 @@ import { parsePaytoUri, stringToBytes, } from "@gnu-taler/taler-util"; -import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; +import { + readResponseJsonOrThrow, + readSuccessResponseJsonOrThrow, + readTalerErrorResponse, + throwUnexpectedRequestError, +} from "@gnu-taler/taler-util/http"; import { selectPayCoins, selectPayCoinsInTx } from "./coinSelection.js"; import { PendingTaskType, @@ -90,20 +98,24 @@ import { DepositInfoPerExchange, DepositOperationStatus, DepositTrackingInfo, - KycPendingInfo, RefreshOperationStatus, WalletDbAllStoresReadOnlyTransaction, WalletDbReadWriteTransaction, + timestampAbsoluteFromDb, timestampPreciseFromDb, timestampPreciseToDb, timestampProtocolFromDb, timestampProtocolToDb, } from "./db.js"; import { + ReadyExchangeSummary, + fetchFreshExchange, getExchangeWireDetailsInTx, getExchangeWireFee, getScopeForAllExchanges, } from "./exchanges.js"; +import { EddsaKeyPairStrings } from "./index.js"; +import { checkDepositHardLimitExceeded, getDepositLimitInfo } from "./kyc.js"; import { extractContractData, generateDepositPermissions, @@ -121,6 +133,7 @@ import { parseTransactionIdentifier, } from "./transactions.js"; import { WalletExecutionContext, getDenomInfo } from "./wallet.js"; +import { augmentPaytoUrisForKycTransfer } from "./withdraw.js"; /** * Logger. @@ -193,6 +206,42 @@ export class DepositTransactionContext implements TransactionContext { dg.statusPerCoin.length; } + let kycAuthTransferInfo: KycAuthTransferInfo | undefined = undefined; + + switch (dg.operationStatus) { + case DepositOperationStatus.PendingDepositKycAuth: + case DepositOperationStatus.SuspendedDepositKycAuth: { + if (!dg.kycInfo) { + break; + } + const plainCreditPaytoUris: string[] = []; + const exchangeWire = await getExchangeWireDetailsInTx( + tx, + dg.kycInfo.exchangeBaseUrl, + ); + if (exchangeWire) { + for (const acc of exchangeWire.wireInfo.accounts) { + if (acc.conversion_url) { + // Conversion accounts do not work for KYC auth! + continue; + } + plainCreditPaytoUris.push(acc.payto_uri); + } + } + kycAuthTransferInfo = { + debitPaytoUri: dg.wire.payto_uri, + accountPub: dg.merchantPub, + creditPaytoUris: augmentPaytoUrisForKycTransfer( + plainCreditPaytoUris, + dg.kycInfo?.paytoHash, + // FIXME: Query tiny amount from exchange. + `${dg.currency}:0.01`, + ), + }; + break; + } + } + const txState = computeDepositTransactionStatus(dg); return { type: TransactionType.Deposit, @@ -217,6 +266,17 @@ export class DepositTransactionContext implements TransactionContext { depositGroupId: dg.depositGroupId, trackingState, deposited, + abortReason: dg.abortReason, + failReason: dg.failReason, + kycAuthTransferInfo, + kycPaytoHash: dg.kycInfo?.paytoHash, + kycAccessToken: dg.kycInfo?.accessToken, + kycUrl: dg.kycInfo + ? new URL( + `kyc-spa/${dg.kycInfo.accessToken}`, + dg.kycInfo.exchangeBaseUrl, + ).href + : undefined, ...(ort?.lastError ? { error: ort.lastError } : {}), }; } @@ -277,11 +337,25 @@ export class DepositTransactionContext implements TransactionContext { const oldState = computeDepositTransactionStatus(dg); let newOpStatus: DepositOperationStatus | undefined; switch (dg.operationStatus) { + case DepositOperationStatus.AbortedDeposit: + case DepositOperationStatus.FailedDeposit: + case DepositOperationStatus.FailedTrack: + case DepositOperationStatus.Finished: + case DepositOperationStatus.SuspendedAborting: + case DepositOperationStatus.SuspendedAggregateKyc: + case DepositOperationStatus.SuspendedDeposit: + case DepositOperationStatus.SuspendedDepositKyc: + case DepositOperationStatus.SuspendedTrack: + case DepositOperationStatus.SuspendedDepositKycAuth: + break; + case DepositOperationStatus.PendingDepositKyc: + newOpStatus = DepositOperationStatus.SuspendedDepositKyc; + break; case DepositOperationStatus.PendingDeposit: newOpStatus = DepositOperationStatus.SuspendedDeposit; break; - case DepositOperationStatus.PendingKyc: - newOpStatus = DepositOperationStatus.SuspendedKyc; + case DepositOperationStatus.PendingAggregateKyc: + newOpStatus = DepositOperationStatus.SuspendedAggregateKyc; break; case DepositOperationStatus.PendingTrack: newOpStatus = DepositOperationStatus.SuspendedTrack; @@ -289,6 +363,11 @@ export class DepositTransactionContext implements TransactionContext { case DepositOperationStatus.Aborting: newOpStatus = DepositOperationStatus.SuspendedAborting; break; + case DepositOperationStatus.PendingDepositKycAuth: + newOpStatus = DepositOperationStatus.SuspendedDepositKycAuth; + break; + default: + assertUnreachable(dg.operationStatus); } if (!newOpStatus) { return undefined; @@ -306,7 +385,7 @@ export class DepositTransactionContext implements TransactionContext { notifyTransition(wex, transactionId, transitionInfo); } - async abortTransaction(): Promise<void> { + async abortTransaction(reason?: TalerErrorDetail): Promise<void> { const { wex, depositGroupId, transactionId, taskId: retryTag } = this; const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["depositGroups", "transactionsMeta"] }, @@ -320,11 +399,14 @@ export class DepositTransactionContext implements TransactionContext { } const oldState = computeDepositTransactionStatus(dg); switch (dg.operationStatus) { - case DepositOperationStatus.Finished: - return undefined; + case DepositOperationStatus.PendingDepositKyc: + case DepositOperationStatus.SuspendedDepositKyc: + case DepositOperationStatus.PendingDepositKycAuth: + case DepositOperationStatus.SuspendedDepositKycAuth: case DepositOperationStatus.PendingDeposit: case DepositOperationStatus.SuspendedDeposit: { dg.operationStatus = DepositOperationStatus.Aborting; + dg.abortReason = reason; await tx.depositGroups.put(dg); await this.updateTransactionMeta(tx); return { @@ -332,6 +414,19 @@ export class DepositTransactionContext implements TransactionContext { newTxState: computeDepositTransactionStatus(dg), }; } + case DepositOperationStatus.PendingTrack: + case DepositOperationStatus.SuspendedTrack: + case DepositOperationStatus.AbortedDeposit: + case DepositOperationStatus.Aborting: + case DepositOperationStatus.FailedDeposit: + case DepositOperationStatus.FailedTrack: + case DepositOperationStatus.Finished: + case DepositOperationStatus.SuspendedAborting: + case DepositOperationStatus.PendingAggregateKyc: + case DepositOperationStatus.SuspendedAggregateKyc: + break; + default: + assertUnreachable(dg.operationStatus); } return undefined; }, @@ -339,10 +434,6 @@ export class DepositTransactionContext implements TransactionContext { wex.taskScheduler.stopShepherdTask(retryTag); notifyTransition(wex, transactionId, transitionInfo); wex.taskScheduler.startShepherdTask(retryTag); - wex.ws.notify({ - type: NotificationType.BalanceChange, - hintTransactionId: transactionId, - }); } async resumeTransaction(): Promise<void> { @@ -360,18 +451,37 @@ export class DepositTransactionContext implements TransactionContext { const oldState = computeDepositTransactionStatus(dg); let newOpStatus: DepositOperationStatus | undefined; switch (dg.operationStatus) { + case DepositOperationStatus.AbortedDeposit: + case DepositOperationStatus.Aborting: + case DepositOperationStatus.FailedDeposit: + case DepositOperationStatus.FailedTrack: + case DepositOperationStatus.Finished: + case DepositOperationStatus.PendingAggregateKyc: + case DepositOperationStatus.PendingDeposit: + case DepositOperationStatus.PendingDepositKyc: + case DepositOperationStatus.PendingTrack: + case DepositOperationStatus.PendingDepositKycAuth: + break; + case DepositOperationStatus.SuspendedDepositKyc: + newOpStatus = DepositOperationStatus.PendingDepositKyc; + break; case DepositOperationStatus.SuspendedDeposit: newOpStatus = DepositOperationStatus.PendingDeposit; break; case DepositOperationStatus.SuspendedAborting: newOpStatus = DepositOperationStatus.Aborting; break; - case DepositOperationStatus.SuspendedKyc: - newOpStatus = DepositOperationStatus.PendingKyc; + case DepositOperationStatus.SuspendedAggregateKyc: + newOpStatus = DepositOperationStatus.PendingAggregateKyc; break; case DepositOperationStatus.SuspendedTrack: newOpStatus = DepositOperationStatus.PendingTrack; break; + case DepositOperationStatus.SuspendedDepositKycAuth: + newOpStatus = DepositOperationStatus.PendingDepositKycAuth; + break; + default: + assertUnreachable(dg.operationStatus); } if (!newOpStatus) { return undefined; @@ -389,7 +499,7 @@ export class DepositTransactionContext implements TransactionContext { wex.taskScheduler.startShepherdTask(retryTag); } - async failTransaction(): Promise<void> { + async failTransaction(reason?: TalerErrorDetail): Promise<void> { const { wex, depositGroupId, transactionId, taskId } = this; const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["depositGroups", "transactionsMeta"] }, @@ -402,27 +512,46 @@ export class DepositTransactionContext implements TransactionContext { return undefined; } const oldState = computeDepositTransactionStatus(dg); + let newState: DepositOperationStatus; switch (dg.operationStatus) { + case DepositOperationStatus.PendingAggregateKyc: + case DepositOperationStatus.SuspendedAggregateKyc: case DepositOperationStatus.SuspendedAborting: case DepositOperationStatus.Aborting: { - dg.operationStatus = DepositOperationStatus.Failed; - await tx.depositGroups.put(dg); - await this.updateTransactionMeta(tx); - return { - oldTxState: oldState, - newTxState: computeDepositTransactionStatus(dg), - }; + newState = DepositOperationStatus.FailedDeposit; + break; } + case DepositOperationStatus.PendingTrack: + case DepositOperationStatus.SuspendedTrack: { + newState = DepositOperationStatus.FailedTrack; + break; + } + case DepositOperationStatus.AbortedDeposit: + case DepositOperationStatus.FailedDeposit: + case DepositOperationStatus.FailedTrack: + case DepositOperationStatus.Finished: + case DepositOperationStatus.PendingDeposit: + case DepositOperationStatus.PendingDepositKyc: + case DepositOperationStatus.PendingDepositKycAuth: + case DepositOperationStatus.SuspendedDeposit: + case DepositOperationStatus.SuspendedDepositKyc: + case DepositOperationStatus.SuspendedDepositKycAuth: + throw Error("failing not supported in current state"); + default: + assertUnreachable(dg.operationStatus); } - return undefined; + dg.operationStatus = newState; + dg.failReason = reason; + await tx.depositGroups.put(dg); + await this.updateTransactionMeta(tx); + return { + oldTxState: oldState, + newTxState: computeDepositTransactionStatus(dg), + }; }, ); wex.taskScheduler.stopShepherdTask(taskId); notifyTransition(wex, transactionId, transitionInfo); - wex.ws.notify({ - type: NotificationType.BalanceChange, - hintTransactionId: transactionId, - }); } } @@ -443,7 +572,7 @@ export function computeDepositTransactionStatus( major: TransactionMajorState.Pending, minor: TransactionMinorState.Deposit, }; - case DepositOperationStatus.PendingKyc: + case DepositOperationStatus.PendingAggregateKyc: return { major: TransactionMajorState.Pending, minor: TransactionMinorState.KycRequired, @@ -453,7 +582,7 @@ export function computeDepositTransactionStatus( major: TransactionMajorState.Pending, minor: TransactionMinorState.Track, }; - case DepositOperationStatus.SuspendedKyc: + case DepositOperationStatus.SuspendedAggregateKyc: return { major: TransactionMajorState.Suspended, minor: TransactionMinorState.KycRequired, @@ -471,18 +600,48 @@ export function computeDepositTransactionStatus( return { major: TransactionMajorState.Aborting, }; - case DepositOperationStatus.Aborted: + case DepositOperationStatus.AbortedDeposit: return { major: TransactionMajorState.Aborted, }; - case DepositOperationStatus.Failed: + case DepositOperationStatus.FailedDeposit: return { major: TransactionMajorState.Failed, + minor: TransactionMinorState.Deposit, + }; + case DepositOperationStatus.FailedTrack: + return { + major: TransactionMajorState.Failed, + minor: TransactionMinorState.Track, }; case DepositOperationStatus.SuspendedAborting: return { major: TransactionMajorState.SuspendedAborting, }; + case DepositOperationStatus.PendingDepositKyc: + return { + major: TransactionMajorState.Pending, + // We lie to the UI by hiding the specific KYC state. + minor: TransactionMinorState.KycRequired, + }; + case DepositOperationStatus.SuspendedDepositKyc: + return { + major: TransactionMajorState.Suspended, + // We lie to the UI by hiding the specific KYC state. + minor: TransactionMinorState.KycRequired, + }; + case DepositOperationStatus.PendingDepositKycAuth: + return { + major: TransactionMajorState.Pending, + // We lie to the UI by hiding the specific KYC state. + minor: TransactionMinorState.KycAuthRequired, + }; + case DepositOperationStatus.SuspendedDepositKycAuth: + return { + major: TransactionMajorState.Suspended, + // We lie to the UI by hiding the specific KYC state. + minor: TransactionMinorState.KycAuthRequired, + }; default: assertUnreachable(dg.operationStatus); } @@ -512,13 +671,14 @@ export function computeDepositTransactionActions( TransactionAction.Fail, TransactionAction.Suspend, ]; - case DepositOperationStatus.Aborted: + case DepositOperationStatus.AbortedDeposit: return [TransactionAction.Delete]; - case DepositOperationStatus.Failed: + case DepositOperationStatus.FailedDeposit: + case DepositOperationStatus.FailedTrack: return [TransactionAction.Delete]; case DepositOperationStatus.SuspendedAborting: return [TransactionAction.Resume, TransactionAction.Fail]; - case DepositOperationStatus.PendingKyc: + case DepositOperationStatus.PendingAggregateKyc: return [ TransactionAction.Retry, TransactionAction.Suspend, @@ -528,11 +688,19 @@ export function computeDepositTransactionActions( return [ TransactionAction.Retry, TransactionAction.Suspend, - TransactionAction.Abort, + TransactionAction.Fail, ]; - case DepositOperationStatus.SuspendedKyc: + case DepositOperationStatus.SuspendedAggregateKyc: return [TransactionAction.Resume, TransactionAction.Fail]; case DepositOperationStatus.SuspendedTrack: + return [TransactionAction.Resume, TransactionAction.Fail]; + case DepositOperationStatus.PendingDepositKyc: + return [TransactionAction.Resume, TransactionAction.Abort]; + case DepositOperationStatus.SuspendedDepositKyc: + return [TransactionAction.Suspend, TransactionAction.Abort]; + case DepositOperationStatus.PendingDepositKycAuth: + return [TransactionAction.Suspend, TransactionAction.Abort]; + case DepositOperationStatus.SuspendedDepositKycAuth: return [TransactionAction.Resume, TransactionAction.Abort]; default: assertUnreachable(dg.operationStatus); @@ -714,14 +882,14 @@ async function waitForRefreshOnDepositGroup( // Maybe it got manually deleted? Means that we should // just go into aborted. logger.warn("no aborting refresh group found for deposit group"); - newOpState = DepositOperationStatus.Aborted; + newOpState = DepositOperationStatus.AbortedDeposit; } else { if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) { - newOpState = DepositOperationStatus.Aborted; + newOpState = DepositOperationStatus.AbortedDeposit; } else if ( refreshGroup.operationStatus === RefreshOperationStatus.Failed ) { - newOpState = DepositOperationStatus.Aborted; + newOpState = DepositOperationStatus.AbortedDeposit; } } if (newOpState) { @@ -741,10 +909,6 @@ async function waitForRefreshOnDepositGroup( ); notifyTransition(wex, ctx.transactionId, transitionInfo); - wex.ws.notify({ - type: NotificationType.BalanceChange, - hintTransactionId: ctx.transactionId, - }); return TaskRunResult.backoff(); } @@ -762,15 +926,16 @@ async function processDepositGroupAborting( return waitForRefreshOnDepositGroup(wex, depositGroup); } +/** + * Process the transaction in states where KYC is required. + * Used for both the deposit KYC and aggregate KYC. + */ async function processDepositGroupPendingKyc( wex: WalletExecutionContext, depositGroup: DepositGroupRecord, ): Promise<TaskRunResult> { const { depositGroupId } = depositGroup; - const transactionId = constructTransactionIdentifier({ - tag: TransactionType.Deposit, - depositGroupId, - }); + const ctx = new DepositTransactionContext(wex, depositGroupId); const kycInfo = depositGroup.kycInfo; @@ -778,8 +943,13 @@ async function processDepositGroupPendingKyc( throw Error("invalid DB state, in pending(kyc), but no kycInfo present"); } + const sigResp = await wex.cryptoApi.signWalletKycAuth({ + accountPriv: depositGroup.merchantPriv, + accountPub: depositGroup.merchantPub, + }); + const url = new URL( - `kyc-check/${kycInfo.requirementRow}`, + `kyc-check/${kycInfo.paytoHash}`, kycInfo.exchangeBaseUrl, ); @@ -792,16 +962,19 @@ async function processDepositGroupPendingKyc( return await wex.http.fetch(url.href, { method: "GET", cancellationToken: wex.cancellationToken, + headers: { + ["Account-Owner-Signature"]: sigResp.sig, + }, }); }, ); - const ctx = new DepositTransactionContext(wex, depositGroupId); + logger.trace( + `request to ${kycStatusRes.requestUrl} returned status ${kycStatusRes.status}`, + ); if ( kycStatusRes.status === HttpStatusCode.Ok || - //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge - // remove after the exchange is fixed or clarified kycStatusRes.status === HttpStatusCode.NoContent ) { const transitionInfo = await wex.db.runReadWriteTx( @@ -811,26 +984,176 @@ async function processDepositGroupPendingKyc( if (!newDg) { return; } - if (newDg.operationStatus !== DepositOperationStatus.PendingKyc) { - return; - } const oldTxState = computeDepositTransactionStatus(newDg); - newDg.operationStatus = DepositOperationStatus.PendingTrack; - const newTxState = computeDepositTransactionStatus(newDg); + switch (newDg.operationStatus) { + case DepositOperationStatus.PendingAggregateKyc: + newDg.operationStatus = DepositOperationStatus.PendingTrack; + break; + case DepositOperationStatus.PendingDepositKyc: + newDg.operationStatus = DepositOperationStatus.PendingDeposit; + break; + default: + return; + } await tx.depositGroups.put(newDg); await ctx.updateTransactionMeta(tx); + const newTxState = computeDepositTransactionStatus(newDg); return { oldTxState, newTxState }; }, ); - notifyTransition(wex, transactionId, transitionInfo); + notifyTransition(wex, ctx.transactionId, transitionInfo); } else if (kycStatusRes.status === HttpStatusCode.Accepted) { - // FIXME: Do we have to update the URL here? + const statusResp = await readResponseJsonOrThrow( + kycStatusRes, + codecForAccountKycStatus(), + ); + logger.info(`kyc still pending (HTTP 202): ${j2s(statusResp)}`); } else { - throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`); + throwUnexpectedRequestError( + kycStatusRes, + await readTalerErrorResponse(kycStatusRes), + ); } return TaskRunResult.backoff(); } +async function processDepositGroupPendingKycAuth( + wex: WalletExecutionContext, + depositGroup: DepositGroupRecord, +): Promise<TaskRunResult> { + const { depositGroupId } = depositGroup; + const ctx = new DepositTransactionContext(wex, depositGroupId); + + const kycInfo = depositGroup.kycInfo; + + if (!kycInfo) { + throw Error( + "invalid DB state, in pending(kyc-auth), but no kycInfo present", + ); + } + + const sigResp = await wex.cryptoApi.signWalletKycAuth({ + accountPriv: depositGroup.merchantPriv, + accountPub: depositGroup.merchantPub, + }); + + const url = new URL( + `kyc-check/${kycInfo.paytoHash}`, + kycInfo.exchangeBaseUrl, + ); + + // lpt=1 => wait for the KYC auth transfer (access token available) + url.searchParams.set("lpt", "1"); + + const kycStatusRes = await wex.ws.runLongpollQueueing( + wex, + url.hostname, + async (timeoutMs) => { + url.searchParams.set("timeout_ms", `${timeoutMs}`); + logger.info(`kyc url ${url.href}`); + return await wex.http.fetch(url.href, { + method: "GET", + cancellationToken: wex.cancellationToken, + headers: { + ["Account-Owner-Signature"]: sigResp.sig, + }, + }); + }, + ); + + logger.info(`merchant pub: ${depositGroup.merchantPub}`); + + logger.info( + `kyc-check for auth longpoll result status: ${kycStatusRes.status}`, + ); + + switch (kycStatusRes.status) { + case HttpStatusCode.Ok: + return await transitionKycAuthSuccess(ctx); + case HttpStatusCode.NoContent: + return await transitionKycAuthSuccess(ctx); + case HttpStatusCode.Accepted: + return await transitionKycAuthSuccess(ctx); + case HttpStatusCode.Conflict: + // FIXME: Consider also checking error code + logger.info("kyc still pending"); + return TaskRunResult.longpollReturnedPending(); + default: + throwUnexpectedRequestError( + kycStatusRes, + await readTalerErrorResponse(kycStatusRes), + ); + } +} + +async function transitionKycAuthSuccess( + ctx: DepositTransactionContext, +): Promise<TaskRunResult> { + const transitionInfo = await ctx.wex.db.runReadWriteTx( + { storeNames: ["depositGroups", "transactionsMeta"] }, + async (tx) => { + const newDg = await tx.depositGroups.get(ctx.depositGroupId); + if (!newDg) { + return; + } + const oldTxState = computeDepositTransactionStatus(newDg); + switch (newDg.operationStatus) { + case DepositOperationStatus.PendingDepositKycAuth: + newDg.operationStatus = DepositOperationStatus.PendingDeposit; + break; + default: + return; + } + await tx.depositGroups.put(newDg); + await ctx.updateTransactionMeta(tx); + const newTxState = computeDepositTransactionStatus(newDg); + return { oldTxState, newTxState }; + }, + ); + notifyTransition(ctx.wex, ctx.transactionId, transitionInfo); + if (transitionInfo) { + return TaskRunResult.progress(); + } else { + return TaskRunResult.backoff(); + } +} + +/** + * Finds the reserve key pair of the most recent withdrawal + * with the given exchange. + * Returns undefined if no such withdrawal exists. + */ +async function getLastWithdrawalKeyPair( + wex: WalletExecutionContext, + exchangeBaseUrl: string, +): Promise<EddsaKeyPairStrings | undefined> { + let candidateTimestamp: AbsoluteTime | undefined = undefined; + let candidateRes: EddsaKeyPairStrings | undefined = undefined; + await wex.db.runAllStoresReadOnlyTx({}, async (tx) => { + const withdrawalRecs = + await tx.withdrawalGroups.indexes.byExchangeBaseUrl.getAll( + exchangeBaseUrl, + ); + for (const rec of withdrawalRecs) { + if (!rec.timestampFinish) { + continue; + } + const currTimestamp = timestampAbsoluteFromDb(rec.timestampFinish); + if ( + candidateTimestamp == null || + AbsoluteTime.cmp(currTimestamp, candidateTimestamp) > 0 + ) { + candidateTimestamp = currTimestamp; + candidateRes = { + priv: rec.reservePriv, + pub: rec.reservePub, + }; + } + } + }); + return candidateRes; +} + /** * Tracking information from the exchange indicated that * KYC is required. We need to check the KYC info @@ -839,24 +1162,35 @@ async function processDepositGroupPendingKyc( async function transitionToKycRequired( wex: WalletExecutionContext, depositGroup: DepositGroupRecord, - kycInfo: KycPendingInfo, + kycPaytoHash: string, exchangeUrl: string, ): Promise<TaskRunResult> { const { depositGroupId } = depositGroup; const ctx = new DepositTransactionContext(wex, depositGroupId); - const url = new URL(`kyc-check/${kycInfo.requirementRow}`, exchangeUrl); + const sigResp = await wex.cryptoApi.signWalletKycAuth({ + accountPriv: depositGroup.merchantPriv, + accountPub: depositGroup.merchantPub, + }); + + const url = new URL(`kyc-check/${kycPaytoHash}`, exchangeUrl); logger.info(`kyc url ${url.href}`); - const kycStatusReq = await wex.http.fetch(url.href, { + const kycStatusResp = await wex.http.fetch(url.href, { method: "GET", + headers: { + ["Account-Owner-Signature"]: sigResp.sig, + }, }); - if (kycStatusReq.status === HttpStatusCode.Ok) { + logger.trace(`response status of initial kyc-check: ${kycStatusResp.status}`); + if (kycStatusResp.status === HttpStatusCode.Ok) { logger.warn("kyc requested, but already fulfilled"); return TaskRunResult.backoff(); - } else if (kycStatusReq.status === HttpStatusCode.Accepted) { - const kycStatus = await kycStatusReq.json(); - logger.info(`kyc status: ${j2s(kycStatus)}`); + } else if (kycStatusResp.status === HttpStatusCode.Accepted) { + const statusResp = await readResponseJsonOrThrow( + kycStatusResp, + codecForAccountKycStatus(), + ); const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["depositGroups", "transactionsMeta"] }, async (tx) => { @@ -864,15 +1198,21 @@ async function transitionToKycRequired( if (!dg) { return undefined; } - if (dg.operationStatus !== DepositOperationStatus.PendingTrack) { - return undefined; - } const oldTxState = computeDepositTransactionStatus(dg); + switch (dg.operationStatus) { + case DepositOperationStatus.PendingTrack: + dg.operationStatus = DepositOperationStatus.PendingAggregateKyc; + break; + case DepositOperationStatus.PendingDeposit: + dg.operationStatus = DepositOperationStatus.PendingDepositKyc; + break; + default: + return; + } dg.kycInfo = { exchangeBaseUrl: exchangeUrl, - kycUrl: kycStatus.kyc_url, - paytoHash: kycInfo.paytoHash, - requirementRow: kycInfo.requirementRow, + paytoHash: kycPaytoHash, + accessToken: statusResp.access_token, }; await tx.depositGroups.put(dg); await ctx.updateTransactionMeta(tx); @@ -881,12 +1221,57 @@ async function transitionToKycRequired( }, ); notifyTransition(wex, ctx.transactionId, transitionInfo); - return TaskRunResult.finished(); + return TaskRunResult.progress(); } else { - throw Error(`unexpected response from kyc-check (${kycStatusReq.status})`); + throwUnexpectedRequestError( + kycStatusResp, + await readTalerErrorResponse(kycStatusResp), + ); } } +async function transitionToKycAuthRequired( + wex: WalletExecutionContext, + depositGroup: DepositGroupRecord, + kycPaytoHash: string, + exchangeUrl: string, +): Promise<TaskRunResult> { + const { depositGroupId } = depositGroup; + + const ctx = new DepositTransactionContext(wex, depositGroupId); + + const transitionInfo = await wex.db.runReadWriteTx( + { storeNames: ["depositGroups", "transactionsMeta"] }, + async (tx) => { + const dg = await tx.depositGroups.get(depositGroupId); + if (!dg) { + return undefined; + } + const oldTxState = computeDepositTransactionStatus(dg); + switch (dg.operationStatus) { + case DepositOperationStatus.PendingTrack: + throw Error("not yet supported"); + break; + case DepositOperationStatus.PendingDeposit: + dg.operationStatus = DepositOperationStatus.PendingDepositKycAuth; + break; + default: + return; + } + dg.kycInfo = { + exchangeBaseUrl: exchangeUrl, + paytoHash: kycPaytoHash, + }; + await tx.depositGroups.put(dg); + await ctx.updateTransactionMeta(tx); + const newTxState = computeDepositTransactionStatus(dg); + return { oldTxState, newTxState }; + }, + ); + notifyTransition(wex, ctx.transactionId, transitionInfo); + return TaskRunResult.progress(); +} + async function processDepositGroupPendingTrack( wex: WalletExecutionContext, depositGroup: DepositGroupRecord, @@ -903,6 +1288,7 @@ async function processDepositGroupPendingTrack( "unable to refund deposit group without coin selection (selection missing)", ); } + logger.trace(`tracking deposit group, status ${j2s(statusPerCoin)}`); const { depositGroupId } = depositGroup; const ctx = new DepositTransactionContext(wex, depositGroupId); for (let i = 0; i < statusPerCoin.length; i++) { @@ -933,20 +1319,16 @@ async function processDepositGroupPendingTrack( exchangeBaseUrl, ); + logger.trace(`track response: ${j2s(track)}`); if (track.type === "accepted") { if (!track.kyc_ok && track.requirement_row !== undefined) { const paytoHash = encodeCrock( hashTruncate32(stringToBytes(depositGroup.wire.payto_uri + "\0")), ); - const { requirement_row: requirementRow } = track; - const kycInfo: KycPendingInfo = { - paytoHash, - requirementRow, - }; return transitionToKycRequired( wex, depositGroup, - kycInfo, + paytoHash, exchangeBaseUrl, ); } else { @@ -1053,10 +1435,6 @@ async function processDepositGroupPendingTrack( ); notifyTransition(wex, ctx.transactionId, transitionInfo); if (allWired) { - wex.ws.notify({ - type: NotificationType.BalanceChange, - hintTransactionId: ctx.transactionId, - }); return TaskRunResult.finished(); } else { return TaskRunResult.longpollReturnedPending(); @@ -1133,6 +1511,7 @@ async function processDepositGroupPendingDeposit( exchanges: contractData.allowedExchanges, }, restrictWireMethod: contractData.wireMethod, + depositPaytoUri: dg.wire.payto_uri, contractTermsAmount: Amounts.parseOrThrow(contractData.amount), depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), prevPayCoins: [], @@ -1203,8 +1582,13 @@ async function processDepositGroupPendingDeposit( exchanges.add(dp.exchange_url); } + const merchantSigResp = await wex.ws.cryptoApi.signContractTermsHash({ + contractTermsHash: depositGroup.contractTermsHash, + merchantPriv: depositGroup.merchantPriv, + }); + // We need to do one batch per exchange. - for (const exchangeUrl of exchanges.values()) { + for (const exchangeBaseUrl of exchanges.values()) { const coins: BatchDepositRequestCoin[] = []; const batchIndexes: number[] = []; @@ -1217,11 +1601,12 @@ async function processDepositGroupPendingDeposit( wire_salt: depositGroup.wire.salt, wire_transfer_deadline: contractTerms.wire_transfer_deadline, refund_deadline: contractTerms.refund_deadline, + merchant_sig: merchantSigResp.sig, }; for (let i = 0; i < depositPermissions.length; i++) { const perm = depositPermissions[i]; - if (perm.exchange_url != exchangeUrl) { + if (perm.exchange_url != exchangeBaseUrl) { continue; } coins.push({ @@ -1237,7 +1622,7 @@ async function processDepositGroupPendingDeposit( // Check for cancellation before making network request. cancellationToken?.throwIfCancelled(); - const url = new URL(`batch-deposit`, exchangeUrl); + const url = new URL(`batch-deposit`, exchangeBaseUrl); logger.info(`depositing to ${url.href}`); logger.trace(`deposit request: ${j2s(batchReq)}`); const httpResp = await wex.http.fetch(url.href, { @@ -1245,6 +1630,37 @@ async function processDepositGroupPendingDeposit( body: batchReq, cancellationToken: cancellationToken, }); + + switch (httpResp.status) { + case HttpStatusCode.Accepted: + case HttpStatusCode.Ok: + break; + case HttpStatusCode.UnavailableForLegalReasons: { + const kycLegiNeededResp = await readResponseJsonOrThrow( + httpResp, + codecForLegitimizationNeededResponse(), + ); + logger.info( + `kyc legitimization needed response: ${j2s(kycLegiNeededResp)}`, + ); + if (kycLegiNeededResp.bad_kyc_auth) { + return transitionToKycAuthRequired( + wex, + depositGroup, + kycLegiNeededResp.h_payto, + exchangeBaseUrl, + ); + } else { + return transitionToKycRequired( + wex, + depositGroup, + kycLegiNeededResp.h_payto, + exchangeBaseUrl, + ); + } + } + } + await readSuccessResponseJsonOrThrow( httpResp, codecForBatchDepositSuccess(), @@ -1318,12 +1734,15 @@ export async function processDepositGroup( switch (depositGroup.operationStatus) { case DepositOperationStatus.PendingTrack: return processDepositGroupPendingTrack(wex, depositGroup); - case DepositOperationStatus.PendingKyc: + case DepositOperationStatus.PendingAggregateKyc: + case DepositOperationStatus.PendingDepositKyc: return processDepositGroupPendingKyc(wex, depositGroup); case DepositOperationStatus.PendingDeposit: return processDepositGroupPendingDeposit(wex, depositGroup); case DepositOperationStatus.Aborting: return processDepositGroupAborting(wex, depositGroup); + case DepositOperationStatus.PendingDepositKycAuth: + return processDepositGroupPendingKycAuth(wex, depositGroup); } return TaskRunResult.finished(); @@ -1357,6 +1776,8 @@ async function trackDeposit( url.hostname, async (timeoutMs) => { url.searchParams.set("timeout_ms", `${timeoutMs}`); + // wait for the a 202 state where kyc_ok is false or a 200 OK response + url.searchParams.set("lpt", `1`); return await wex.http.fetch(url.href, { method: "GET", cancellationToken: wex.cancellationToken, @@ -1370,6 +1791,7 @@ async function trackDeposit( httpResp, codecForTackTransactionAccepted(), ); + logger.trace(`deposits response: ${j2s(accepted)}`); return { type: "accepted", ...accepted }; } case HttpStatusCode.Ok: { @@ -1380,8 +1802,9 @@ async function trackDeposit( return { type: "wired", ...wired }; } default: { - throw Error( - `unexpected response from track-transaction (${httpResp.status})`, + throwUnexpectedRequestError( + httpResp, + await readTalerErrorResponse(httpResp), ); } } @@ -1393,8 +1816,8 @@ async function trackDeposit( */ export async function checkDepositGroup( wex: WalletExecutionContext, - req: PrepareDepositRequest, -): Promise<PrepareDepositResponse> { + req: CheckDepositRequest, +): Promise<CheckDepositResponse> { return await runWithClientCancellation( wex, "checkDepositGroup", @@ -1409,8 +1832,8 @@ export async function checkDepositGroup( */ export async function internalCheckDepositGroup( wex: WalletExecutionContext, - req: PrepareDepositRequest, -): Promise<PrepareDepositResponse> { + req: CheckDepositRequest, +): Promise<CheckDepositResponse> { const p = parsePaytoUri(req.depositPaytoUri); if (!p) { throw Error("invalid payto URI"); @@ -1418,7 +1841,7 @@ export async function internalCheckDepositGroup( const amount = Amounts.parseOrThrow(req.amount); const currency = Amounts.currencyOf(amount); - const exchangeInfos: ExchangeHandle[] = []; + const exchangeInfos: Exchange[] = []; await wex.db.runReadOnlyTx( { storeNames: ["exchangeDetails", "exchanges"] }, @@ -1431,6 +1854,7 @@ export async function internalCheckDepositGroup( } exchangeInfos.push({ master_pub: details.masterPublicKey, + priority: 1, url: e.baseUrl, }); } @@ -1477,6 +1901,7 @@ export async function internalCheckDepositGroup( exchanges: contractData.allowedExchanges, }, restrictWireMethod: contractData.wireMethod, + depositPaytoUri: req.depositPaytoUri, contractTermsAmount: Amounts.parseOrThrow(contractData.amount), depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), prevPayCoins: [], @@ -1510,6 +1935,17 @@ export async function internalCheckDepositGroup( selCoins, ); + const usedExchangesSet = new Set<string>(); + for (const c of selCoins) { + usedExchangesSet.add(c.exchangeBaseUrl); + } + + const exchanges: ReadyExchangeSummary[] = []; + + for (const exchangeBaseUrl of usedExchangesSet) { + exchanges.push(await fetchFreshExchange(wex, exchangeBaseUrl)); + } + const fees = await getTotalFeesForDepositAmount( wex, p.targetType, @@ -1521,6 +1957,7 @@ export async function internalCheckDepositGroup( totalDepositCost: Amounts.stringify(totalDepositCost), effectiveDepositAmount: Amounts.stringify(effectiveDepositAmount), fees, + ...getDepositLimitInfo(exchanges, effectiveDepositAmount), }; } @@ -1536,8 +1973,8 @@ export async function createDepositGroup( wex: WalletExecutionContext, req: CreateDepositGroupRequest, ): Promise<CreateDepositGroupResponse> { - const p = parsePaytoUri(req.depositPaytoUri); - if (!p) { + const depositPayto = parsePaytoUri(req.depositPaytoUri); + if (!depositPayto) { throw Error("invalid payto URI"); } @@ -1568,15 +2005,88 @@ export async function createDepositGroup( AbsoluteTime.addDuration(now, Duration.fromSpec({ minutes: 5 })), ); const nowRounded = AbsoluteTime.toProtocolTimestamp(now); + + const payCoinSel = await selectPayCoins(wex, { + restrictExchanges: { + auditors: [], + exchanges: exchangeInfos.map((x) => ({ + exchangeBaseUrl: x.url, + exchangePub: x.master_pub, + })), + }, + restrictWireMethod: depositPayto.targetType, + depositPaytoUri: req.depositPaytoUri, + contractTermsAmount: amount, + depositFeeLimit: amount, + prevPayCoins: [], + }); + + let coins: SelectedProspectiveCoin[] | undefined = undefined; + + switch (payCoinSel.type) { + case "success": + coins = payCoinSel.coinSel.coins; + break; + case "failure": + throw TalerError.fromDetail( + TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE, + { + insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails, + }, + ); + case "prospective": + coins = payCoinSel.result.prospectiveCoins; + break; + default: + assertUnreachable(payCoinSel); + } + + const usedExchangesSet = new Set<string>(); + for (const c of coins) { + usedExchangesSet.add(c.exchangeBaseUrl); + } + + const exchanges: ReadyExchangeSummary[] = []; + + for (const exchangeBaseUrl of usedExchangesSet) { + exchanges.push(await fetchFreshExchange(wex, exchangeBaseUrl)); + } + + if (checkDepositHardLimitExceeded(exchanges, req.amount)) { + throw Error("deposit would exceed hard KYC limit"); + } + + // Heuristic for the merchant key pair: If there's an exchange where we made + // a withdrawal from, use that key pair, so the user doesn't have to do + // a KYC transfer to establish a kyc account key pair. + // FIXME: Extend the heuristic to use the last used merchant key pair? + let merchantPair: EddsaKeyPairStrings | undefined = undefined; + if (coins.length > 0) { + const res = await getLastWithdrawalKeyPair(wex, coins[0].exchangeBaseUrl); + if (res) { + logger.info( + `reusing reserve pub ${res.pub} from last withdrawal to ${coins[0].exchangeBaseUrl}`, + ); + merchantPair = res; + } + } + if (!merchantPair) { + logger.info(`creating new merchant key pair for deposit`); + merchantPair = await wex.cryptoApi.createEddsaKeypair({}); + } + const noncePair = await wex.cryptoApi.createEddsaKeypair({}); - const merchantPair = await wex.cryptoApi.createEddsaKeypair({}); const wireSalt = encodeCrock(getRandomBytes(16)); const wireHash = hashWire(req.depositPaytoUri, wireSalt); const contractTerms: MerchantContractTerms = { - exchanges: exchangeInfos, + exchanges: exchangeInfos.map((x) => ({ + master_pub: x.master_pub, + priority: 1, + url: x.url, + })), amount: req.amount, max_fee: Amounts.stringify(amount), - wire_method: p.targetType, + wire_method: depositPayto.targetType, timestamp: nowRounded, merchant_base_url: "", summary: "", @@ -1604,37 +2114,6 @@ export async function createDepositGroup( "", ); - const payCoinSel = await selectPayCoins(wex, { - restrictExchanges: { - auditors: [], - exchanges: contractData.allowedExchanges, - }, - restrictWireMethod: contractData.wireMethod, - contractTermsAmount: Amounts.parseOrThrow(contractData.amount), - depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), - prevPayCoins: [], - }); - - let coins: SelectedProspectiveCoin[] | undefined = undefined; - - switch (payCoinSel.type) { - case "success": - coins = payCoinSel.coinSel.coins; - break; - case "failure": - throw TalerError.fromDetail( - TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE, - { - insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails, - }, - ); - case "prospective": - coins = payCoinSel.result.prospectiveCoins; - break; - default: - assertUnreachable(payCoinSel); - } - const totalDepositCost = await getTotalPaymentCost(wex, currency, coins); let depositGroupId: string; @@ -1666,7 +2145,11 @@ export async function createDepositGroup( } const counterpartyEffectiveDepositAmount = - await getCounterpartyEffectiveDepositAmount(wex, p.targetType, coins); + await getCounterpartyEffectiveDepositAmount( + wex, + depositPayto.targetType, + coins, + ); const depositGroup: DepositGroupRecord = { contractTermsHash, @@ -1749,20 +2232,13 @@ export async function createDepositGroup( }, ); - wex.ws.notify({ - type: NotificationType.TransactionStateTransition, - transactionId, + notifyTransition(wex, transactionId, { oldTxState: { major: TransactionMajorState.None, }, newTxState, }); - wex.ws.notify({ - type: NotificationType.BalanceChange, - hintTransactionId: transactionId, - }); - wex.taskScheduler.startShepherdTask(ctx.taskId); return { diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts index 7b8e57c8b..560e660a5 100644 --- a/packages/taler-wallet-core/src/exchanges.ts +++ b/packages/taler-wallet-core/src/exchanges.ts @@ -26,6 +26,7 @@ import { AbsoluteTime, AccountKycStatus, + AccountLimit, AgeRestriction, Amount, AmountLike, @@ -62,6 +63,7 @@ import { HttpStatusCode, LegitimizationNeededResponse, LibtoolVersion, + ListExchangesRequest, Logger, NotificationType, OperationErrorInfo, @@ -91,6 +93,7 @@ import { WireFeeMap, WireFeesJson, WireInfo, + ZeroLimitedOperation, assertUnreachable, checkDbInvariant, checkLogicInvariant, @@ -101,10 +104,12 @@ import { encodeCrock, getRandomBytes, hashDenomPub, + hashPaytoUri, j2s, makeErrorDetail, makeTalerErrorDetail, parsePaytoUri, + stringifyReservePaytoUri, } from "@gnu-taler/taler-util"; import { HttpRequestLibrary, @@ -143,6 +148,7 @@ import { ReserveRecord, ReserveRecordStatus, WalletDbAllStoresReadOnlyTransaction, + WalletDbAllStoresReadWriteTransaction, WalletDbHelpers, WalletDbReadOnlyTransaction, WalletDbReadWriteTransaction, @@ -166,6 +172,7 @@ import { createRefreshGroup } from "./refresh.js"; import { constructTransactionIdentifier, notifyTransition, + rematerializeTransactions, } from "./transactions.js"; import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "./versions.js"; import { InternalWalletState, WalletExecutionContext } from "./wallet.js"; @@ -281,6 +288,9 @@ export async function getScopeForAllCoins( return rs.filter((d): d is ScopeInfo => d !== undefined); } +/** + * Get a list of scope infos applicable to a list of exchanges. + */ export async function getScopeForAllExchanges( tx: WalletDbReadOnlyTransaction< [ @@ -850,6 +860,8 @@ export interface ExchangeKeysDownloadResult { wireFees: { [methodName: string]: WireFeesJson[] }; currencySpecification?: CurrencySpecification; walletBalanceLimits: AmountString[] | undefined; + hardLimits: AccountLimit[] | undefined; + zeroLimits: ZeroLimitedOperation[] | undefined; } /** @@ -1015,6 +1027,8 @@ async function downloadExchangeKeysInfo( currencySpecification: exchangeKeysJsonUnchecked.currency_specification, walletBalanceLimits: exchangeKeysJsonUnchecked.wallet_balance_limit_without_kyc, + hardLimits: exchangeKeysJsonUnchecked.hard_limits, + zeroLimits: exchangeKeysJsonUnchecked.zero_limits, }; } @@ -1255,6 +1269,9 @@ export interface ReadyExchangeSummary { protocolVersionRange: string; tosAcceptedTimestamp: TalerPreciseTimestamp | undefined; scopeInfo: ScopeInfo; + walletBalanceLimitWithoutKyc: AmountString[] | undefined; + zeroLimits: ZeroLimitedOperation[]; + hardLimits: AccountLimit[]; } /** @@ -1419,6 +1436,9 @@ async function waitReadyExchange( exchange.tosAcceptedTimestamp, ), scopeInfo, + walletBalanceLimitWithoutKyc: exchangeDetails.walletBalanceLimits, + hardLimits: exchangeDetails.hardLimits ?? [], + zeroLimits: exchangeDetails.zeroLimits ?? [], }; if (options.expectedMasterPub) { @@ -1745,6 +1765,8 @@ export async function updateExchangeFromUrlHandler( wireInfo, ageMask, walletBalanceLimits: keysInfo.walletBalanceLimits, + hardLimits: keysInfo.hardLimits, + zeroLimits: keysInfo.zeroLimits, }; r.noFees = noFees; r.peerPaymentsDisabled = peerPaymentsDisabled; @@ -2501,6 +2523,7 @@ export async function downloadExchangeInfo( */ export async function listExchanges( wex: WalletExecutionContext, + req: ListExchangesRequest, ): Promise<ExchangesListResponse> { const exchanges: ExchangeListItem[] = []; await wex.db.runReadOnlyTx( @@ -2533,15 +2556,30 @@ export async function listExchanges( ); checkDbInvariant(!!reserveRec, "reserve record not found"); } - exchanges.push( - await makeExchangeListItem( + if (req.filterByScope) { + const inScope = await checkExchangeInScopeTx( + wex, tx, - exchangeRec, - exchangeDetails, - reserveRec, - opRetryRecord?.lastError, - ), + exchangeRec.baseUrl, + req.filterByScope, + ); + if (!inScope) { + continue; + } + } + const li = await makeExchangeListItem( + tx, + exchangeRec, + exchangeDetails, + reserveRec, + opRetryRecord?.lastError, ); + if (req.filterByExchangeEntryStatus) { + if (req.filterByExchangeEntryStatus !== li.exchangeEntryStatus) { + continue; + } + } + exchanges.push(li); } }, ); @@ -2776,27 +2814,20 @@ async function internalGetExchangeResources( * but keeps some transactions (payments, p2p, refreshes) around. */ async function purgeExchange( - tx: WalletDbReadWriteTransaction< - [ - "exchanges", - "exchangeDetails", - "transactionsMeta", - "coinAvailability", - "coins", - "denominations", - "exchangeSignKeys", - "withdrawalGroups", - "planchets", - ] - >, + wex: WalletExecutionContext, + tx: WalletDbAllStoresReadWriteTransaction, exchangeBaseUrl: string, ): Promise<void> { const detRecs = await tx.exchangeDetails.indexes.byExchangeBaseUrl.getAll(); + // Remove all exchange detail records for that exchange for (const r of detRecs) { if (r.rowId == null) { // Should never happen, as rowId is the primary key. continue; } + if (r.exchangeBaseUrl !== exchangeBaseUrl) { + continue; + } await tx.exchangeDetails.delete(r.rowId); const signkeyRecs = await tx.exchangeSignKeys.indexes.byExchangeDetailsRowId.getAll(r.rowId); @@ -2851,6 +2882,8 @@ async function purgeExchange( } } } + + await rematerializeTransactions(wex, tx); } export async function deleteExchange( @@ -2859,36 +2892,29 @@ export async function deleteExchange( ): Promise<void> { let inUse: boolean = false; const exchangeBaseUrl = req.exchangeBaseUrl; - await wex.db.runReadWriteTx( - { - storeNames: [ - "exchanges", - "exchangeDetails", - "transactionsMeta", - "coinAvailability", - "coins", - "denominations", - "exchangeSignKeys", - "withdrawalGroups", - "planchets", - ], - }, - async (tx) => { - const exchangeRec = await tx.exchanges.get(exchangeBaseUrl); - if (!exchangeRec) { - // Nothing to delete! - logger.info("no exchange found to delete"); - return; - } - const res = await internalGetExchangeResources(wex, tx, exchangeBaseUrl); - if (res.hasResources && !req.purge) { - inUse = true; - return; - } - await purgeExchange(tx, exchangeBaseUrl); - wex.ws.exchangeCache.clear(); - }, - ); + const notif = await wex.db.runAllStoresReadWriteTx({}, async (tx) => { + const exchangeRec = await tx.exchanges.get(exchangeBaseUrl); + if (!exchangeRec) { + // Nothing to delete! + logger.info("no exchange found to delete"); + return; + } + const oldExchangeState = getExchangeState(exchangeRec); + const res = await internalGetExchangeResources(wex, tx, exchangeBaseUrl); + if (res.hasResources && !req.purge) { + inUse = true; + return; + } + await purgeExchange(wex, tx, exchangeBaseUrl); + wex.ws.exchangeCache.clear(); + + return { + type: NotificationType.ExchangeStateTransition, + oldExchangeState, + newExchangeState: undefined, + exchangeBaseUrl, + } satisfies WalletNotification; + }); if (inUse) { throw TalerError.fromUncheckedDetail({ @@ -2896,6 +2922,9 @@ export async function deleteExchange( hint: "Exchange in use.", }); } + if (notif) { + wex.ws.notify(notif); + } } export async function getExchangeResources( @@ -3388,8 +3417,7 @@ async function handleExchangeKycRespLegi( accountPriv: reserve.reservePriv, accountPub: reserve.reservePub, }); - const requirementRow = kycBody.requirement_row; - const reqUrl = new URL(`kyc-check/${requirementRow}`, exchangeBaseUrl); + const reqUrl = new URL(`kyc-check/${kycBody.h_payto}`, exchangeBaseUrl); const resp = await wex.http.fetch(reqUrl.href, { method: "GET", headers: { @@ -3484,13 +3512,19 @@ async function handleExchangeKycPendingLegitimization( accountPriv: reserve.reservePriv, accountPub: reserve.reservePub, }); - const requirementRow = reserve.requirementRow; - checkDbInvariant(!!requirementRow, "requirement row"); + + const reservePayto = stringifyReservePaytoUri( + exchange.baseUrl, + reserve.reservePub, + ); + + const paytoHash = encodeCrock(hashPaytoUri(reservePayto)); + const resp = await wex.ws.runLongpollQueueing( wex, exchange.baseUrl, async (timeoutMs) => { - const reqUrl = new URL(`kyc-check/${requirementRow}`, exchange.baseUrl); + const reqUrl = new URL(`kyc-check/${paytoHash}`, exchange.baseUrl); reqUrl.searchParams.set("timeout_ms", `${timeoutMs}`); logger.info(`long-polling wallet KYC status at ${reqUrl.href}`); return await wex.http.fetch(reqUrl.href, { @@ -3572,3 +3606,153 @@ export async function processExchangeKyc( ); } } + +export async function checkExchangeInScopeTx( + wex: WalletExecutionContext, + tx: WalletDbReadOnlyTransaction< + [ + "globalCurrencyExchanges", + "globalCurrencyAuditors", + "exchanges", + "exchangeDetails", + ] + >, + exchangeBaseUrl: string, + scope: ScopeInfo, +): Promise<boolean> { + switch (scope.type) { + case ScopeType.Exchange: { + return scope.url === exchangeBaseUrl; + } + case ScopeType.Global: { + const exchangeDetails = await getExchangeRecordsInternal( + tx, + exchangeBaseUrl, + ); + if (!exchangeDetails) { + return false; + } + const gr = await tx.globalCurrencyExchanges.get([ + exchangeDetails.currency, + exchangeBaseUrl, + exchangeDetails.masterPublicKey, + ]); + return gr != null; + } + case ScopeType.Auditor: + 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/host-common.ts b/packages/taler-wallet-core/src/host-common.ts index 7651e5a12..933c36533 100644 --- a/packages/taler-wallet-core/src/host-common.ts +++ b/packages/taler-wallet-core/src/host-common.ts @@ -58,3 +58,31 @@ export function makeTempfileId(length: number): string { } return result; } + +/** + * Get the underlying sqlite3 DB filename + * from the storage path specified by the wallet-core client. + */ +export function getSqlite3FilenameFromStoragePath( + p: string | undefined, +): string { + // Allow specifying a directory as the storage path. + // In that case, we pick the filename and use the sqlite3 + // backend. + // + // We still allow specifying a filename for backwards + // compatibility and control over the exact file. + // + // Specifying a directory allows us to automatically + // migrate to a new DB file in the future. + if (!p) { + return ":memory:"; + } + if (p.endsWith("/")) { + // Current sqlite3 DB filename. + // Should rarely if ever change. + return `${p}talerwalletdb-v30.sqlite3`; + } else { + return p; + } +} diff --git a/packages/taler-wallet-core/src/host-impl.node.ts b/packages/taler-wallet-core/src/host-impl.node.ts index a7f0a5738..eb4191f81 100644 --- a/packages/taler-wallet-core/src/host-impl.node.ts +++ b/packages/taler-wallet-core/src/host-impl.node.ts @@ -41,7 +41,11 @@ import { createPlatformHttpLib } from "@gnu-taler/taler-util/http"; import * as fs from "fs"; import { NodeThreadCryptoWorkerFactory } from "./crypto/workers/nodeThreadWorker.js"; import { SynchronousCryptoWorkerFactoryPlain } from "./crypto/workers/synchronousWorkerFactoryPlain.js"; -import { DefaultNodeWalletArgs, makeTempfileId } from "./host-common.js"; +import { + DefaultNodeWalletArgs, + getSqlite3FilenameFromStoragePath, + makeTempfileId, +} from "./host-common.js"; import { Wallet } from "./wallet.js"; const logger = new Logger("host-impl.node.ts"); @@ -114,7 +118,9 @@ async function makeSqliteDb( BridgeIDBFactory.enableTracing = false; } const imp = await createNodeSqlite3Impl(); - const dbFilename = args.persistentStoragePath ?? ":memory:"; + const dbFilename = getSqlite3FilenameFromStoragePath( + args.persistentStoragePath, + ); logger.info(`using database ${dbFilename}`); const myBackend = await createSqliteBackend(imp, { filename: dbFilename, diff --git a/packages/taler-wallet-core/src/host-impl.qtart.ts b/packages/taler-wallet-core/src/host-impl.qtart.ts index 9c985d0c1..b4ea04be5 100644 --- a/packages/taler-wallet-core/src/host-impl.qtart.ts +++ b/packages/taler-wallet-core/src/host-impl.qtart.ts @@ -32,11 +32,12 @@ import type { import { AccessStats, BridgeIDBFactory, - MemoryBackend, createSqliteBackend, + MemoryBackend, shimIndexedDB, } from "@gnu-taler/idb-bridge"; import { + j2s, Logger, SetTimeoutTimerAPI, WalletRunConfig, @@ -44,7 +45,11 @@ import { import { createPlatformHttpLib } from "@gnu-taler/taler-util/http"; import { qjsOs, qjsStd } from "@gnu-taler/taler-util/qtart"; import { SynchronousCryptoWorkerFactoryPlain } from "./crypto/workers/synchronousWorkerFactoryPlain.js"; -import { DefaultNodeWalletArgs, makeTempfileId } from "./host-common.js"; +import { + DefaultNodeWalletArgs, + getSqlite3FilenameFromStoragePath, + makeTempfileId, +} from "./host-common.js"; import { Wallet } from "./wallet.js"; const logger = new Logger("host-impl.qtart.ts"); @@ -100,9 +105,13 @@ async function makeSqliteDb( args: DefaultNodeWalletArgs, ): Promise<MakeDbResult> { BridgeIDBFactory.enableTracing = false; + const filename = getSqlite3FilenameFromStoragePath( + args.persistentStoragePath, + ); + logger.info(`opening sqlite3 database ${j2s(filename)}`); const imp = await createQtartSqlite3Impl(); const myBackend = await createSqliteBackend(imp, { - filename: args.persistentStoragePath ?? ":memory:", + filename, }); myBackend.trackStats = true; myBackend.enableTracing = false; diff --git a/packages/taler-wallet-core/src/instructedAmountConversion.test.ts b/packages/taler-wallet-core/src/instructedAmountConversion.test.ts index 03e702568..17c2439c7 100644 --- a/packages/taler-wallet-core/src/instructedAmountConversion.test.ts +++ b/packages/taler-wallet-core/src/instructedAmountConversion.test.ts @@ -26,7 +26,6 @@ import { CoinInfo, convertDepositAmountForAvailableCoins, convertWithdrawalAmountFromAvailableCoins, - getMaxDepositAmountForAvailableCoins, } from "./instructedAmountConversion.js"; function makeCurrencyHelper(currency: string) { @@ -254,76 +253,6 @@ test("deposit with wire fee raw 2", (t) => { * */ -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 * @@ -663,42 +592,6 @@ test("demo: withdraw raw 25", (t) => { //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], @@ -729,39 +622,3 @@ test("demo: withdraw raw 13", (t) => { //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/instructedAmountConversion.ts b/packages/taler-wallet-core/src/instructedAmountConversion.ts index 5b399a0a7..c0f835dfa 100644 --- a/packages/taler-wallet-core/src/instructedAmountConversion.ts +++ b/packages/taler-wallet-core/src/instructedAmountConversion.ts @@ -23,12 +23,9 @@ import { Amounts, ConvertAmountRequest, Duration, - GetAmountRequest, - GetPlanForOperationRequest, TransactionAmountMode, TransactionType, checkDbInvariant, - parsePaytoUri, strcmp, } from "@gnu-taler/taler-util"; import { DenominationRecord, timestampProtocolFromDb } from "./db.js"; @@ -85,26 +82,6 @@ interface SelectedCoins { 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 @@ -141,13 +118,13 @@ interface AvailableCoins { * This function is costly (by the database access) but with high chances * of being cached */ -async function getAvailableDenoms( +async function getAvailableCoins( wex: WalletExecutionContext, op: TransactionType, currency: string, filters: CoinsFilter = {}, ): Promise<AvailableCoins> { - const operationType = getOperationType(TransactionType.Deposit); + const operationType = getOperationType(op); return await wex.db.runReadOnlyTx( { @@ -283,7 +260,10 @@ async function getAvailableDenoms( coinAvail.exchangeBaseUrl, coinAvail.denomPubHash, ]); - checkDbInvariant(!!denom, `denomination of a coin is missing hash: ${coinAvail.denomPubHash}`); + checkDbInvariant( + !!denom, + `denomination of a coin is missing hash: ${coinAvail.denomPubHash}`, + ); if (denom.isRevoked || !denom.isOffered) { continue; } @@ -354,7 +334,7 @@ export async function convertDepositAmount( const amount = Amounts.parseOrThrow(req.amount); // const filter = getCoinsFilter(req); - const denoms = await getAvailableDenoms( + const denoms = await getAvailableCoins( wex, TransactionType.Deposit, amount.currency, @@ -374,6 +354,7 @@ export async function convertDepositAmount( const LOG_REFRESH = false; const LOG_DEPOSIT = false; + export function convertDepositAmountForAvailableCoins( denoms: AvailableCoins, amount: AmountJson, @@ -420,9 +401,7 @@ export function convertDepositAmountForAvailableCoins( } const refreshDenoms = rankDenominationForRefresh(denoms.list); - /** - * FIXME: looking for refresh AFTER selecting greedy is not optimal - */ + // FIXME: looking for refresh AFTER selecting greedy is not optimal const refreshCoin = searchBestRefreshCoin( depositDenoms, refreshDenoms, @@ -437,7 +416,7 @@ export function convertDepositAmountForAvailableCoins( refreshCoin.refreshEffective, ).amount; const raw = Amounts.sub(effective, fee, refreshCoin.totalFee).amount; - //found with change + // found with change return { effective, raw, @@ -449,70 +428,13 @@ export function convertDepositAmountForAvailableCoins( return result; } -export async function getMaxDepositAmount( - wex: WalletExecutionContext, - req: GetAmountRequest, -): Promise<AmountResponse> { - // const filter = getCoinsFilter(req); - - const denoms = await getAvailableDenoms( - wex, - 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, -): AmountWithFee { - 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( - wex: WalletExecutionContext, - req: ConvertAmountRequest, -): Promise<AmountResponse> { - throw Error("to be implemented after 1.0"); -} - -export async function getMaxPeerPushAmount( - wex: WalletExecutionContext, - req: GetAmountRequest, -): Promise<AmountResponse> { - throw Error("to be implemented after 1.0"); -} - export async function convertWithdrawalAmount( wex: WalletExecutionContext, req: ConvertAmountRequest, ): Promise<AmountResponse> { const amount = Amounts.parseOrThrow(req.amount); - const denoms = await getAvailableDenoms( + const denoms = await getAvailableCoins( wex, TransactionType.Withdrawal, amount.currency, @@ -553,14 +475,6 @@ export function convertWithdrawalAmountFromAvailableCoins( * ***************************************************** */ -/** - * - * @param depositDenoms - * @param refreshDenoms - * @param amount - * @param mode - * @returns - */ function searchBestRefreshCoin( depositDenoms: SelectableElement[], refreshDenoms: Record<string, SelectableElement[]>, @@ -643,18 +557,13 @@ function searchBestRefreshCoin( /** * 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 - */ + /// Rank coins copyList.sort((d1, d2) => { // the best coin to use is // 1.- the one that contrib more and pay less fee @@ -681,8 +590,8 @@ function rankDenominationForWithdrawals( return copyList.map((info) => { switch (mode) { case TransactionAmountMode.Effective: { - //if the user instructed "effective" then we need to selected - //greedy total coin value + // if the user instructed "effective" then we need to selected + // greedy total coin value return { info, value: info.value, @@ -690,8 +599,8 @@ function rankDenominationForWithdrawals( }; } case TransactionAmountMode.Raw: { - //if the user instructed "raw" then we need to selected - //greedy total coin raw amount (without fee) + // 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, @@ -713,17 +622,15 @@ function rankDenominationForDeposit( mode: TransactionAmountMode, ): SelectableElement[] { const copyList = [...denoms]; - /** - * Rank coins - */ + // 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) + // different exchanges may have different wireFee + // ranking should take the relative contribution in the exchange + // which is (value - denomFee / fixedFee) const rate1 = Amounts.isZero(d1.denomDeposit) ? Number.MIN_SAFE_INTEGER : Amounts.divmod(d1.value, d1.denomDeposit).quotient; @@ -742,8 +649,8 @@ function rankDenominationForDeposit( return copyList.map((info) => { switch (mode) { case TransactionAmountMode.Effective: { - //if the user instructed "effective" then we need to selected - //greedy total coin value + // if the user instructed "effective" then we need to selected + // greedy total coin value return { info, value: info.value, @@ -751,8 +658,8 @@ function rankDenominationForDeposit( }; } case TransactionAmountMode.Raw: { - //if the user instructed "raw" then we need to selected - //greedy total coin raw amount (without fee) + // 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, @@ -765,9 +672,6 @@ function rankDenominationForDeposit( /** * Returns a copy of the list sorted for the best denom to withdraw first - * - * @param denoms - * @returns */ function rankDenominationForRefresh( denoms: CoinInfo[], @@ -817,7 +721,7 @@ function selectGreedyCoins( break iterateDenoms; } - //use Amounts.divmod instead of iterate + // use Amounts.divmod instead of iterate const div = Amounts.divmod(left, denom.value); const size = Math.min(div.quotient, denom.total); if (size > 0) { @@ -829,7 +733,7 @@ function selectGreedyCoins( denom.total = denom.total - size; } - //go next denom + // go next denom denomIdx++; } @@ -839,7 +743,7 @@ function selectGreedyCoins( type AmountWithFee = { raw: AmountJson; effective: AmountJson }; type AmountAndRefresh = AmountWithFee & { refresh?: RefreshChoice }; -export function getTotalEffectiveAndRawForDeposit( +function getTotalEffectiveAndRawForDeposit( list: { info: CoinInfo; size: number }[], currency: string, ): AmountWithFee { diff --git a/packages/taler-wallet-core/src/kyc.ts b/packages/taler-wallet-core/src/kyc.ts new file mode 100644 index 000000000..b28816073 --- /dev/null +++ b/packages/taler-wallet-core/src/kyc.ts @@ -0,0 +1,252 @@ +/* + This file is part of GNU Taler + (C) 2024 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 <http://www.gnu.org/licenses/> + */ + +import { + AmountJson, + AmountLike, + Amounts, + AmountString, +} from "@gnu-taler/taler-util"; +import { ReadyExchangeSummary } from "./exchanges.js"; + +/** + * @fileoverview Helpers for KYC. + * @author Florian Dold <dold@taler.net> + */ + +export interface SimpleLimitInfo { + kycHardLimit: AmountString | undefined; + kycSoftLimit: AmountString | undefined; +} + +export interface MultiExchangeLimitInfo { + kycHardLimit: AmountString | undefined; + kycSoftLimit: AmountString | undefined; + + /** + * Exchanges that would require soft KYC. + */ + kycExchanges: string[]; +} + +/** + * Return the smallest given amount, where an undefined amount + * is interpreted the larger amount. + */ +function minDefAmount( + a: AmountLike | undefined, + b: AmountLike | undefined, +): AmountJson { + if (a == null) { + if (b == null) { + throw Error(); + } + return Amounts.jsonifyAmount(b); + } + if (b == null) { + if (a == null) { + throw Error(); + } + return Amounts.jsonifyAmount(a); + } + return Amounts.min(a, b); +} + +/** + * Add to an amount. + * Interprets the second argument as zero if not defined. + */ +function addDefAmount(a: AmountLike, b: AmountLike | undefined): AmountJson { + if (b == null) { + return Amounts.jsonifyAmount(a); + } + return Amounts.add(a, b).amount; +} + +export function getDepositLimitInfo( + exchanges: ReadyExchangeSummary[], + instructedAmount: AmountLike, +): MultiExchangeLimitInfo { + let kycHardLimit: AmountJson | undefined; + let kycSoftLimit: AmountJson | undefined; + let kycExchanges: string[] = []; + + // FIXME: Summing up the limits doesn't really make a lot of sense, + // as the funds at each exchange are limited (by the coins in the wallet), + // and thus an exchange where we don't have coins but that has a high + // KYC limit can't meaningfully contribute to the whole limit. + + for (const exchange of exchanges) { + const exchLim = getSingleExchangeDepositLimitInfo( + exchange, + instructedAmount, + ); + if (exchLim.kycSoftLimit) { + kycExchanges.push(exchange.exchangeBaseUrl); + kycSoftLimit = addDefAmount(exchLim.kycSoftLimit, kycSoftLimit); + } + if (exchLim.kycHardLimit) { + kycHardLimit = addDefAmount(exchLim.kycHardLimit, kycHardLimit); + } + } + + return { + kycHardLimit: kycHardLimit ? Amounts.stringify(kycHardLimit) : undefined, + kycSoftLimit: kycSoftLimit ? Amounts.stringify(kycSoftLimit) : undefined, + kycExchanges, + }; +} + +export function getSingleExchangeDepositLimitInfo( + exchange: ReadyExchangeSummary, + instructedAmount: AmountLike, +): SimpleLimitInfo { + let kycHardLimit: AmountJson | undefined; + let kycSoftLimit: AmountJson | undefined; + + for (let lim of exchange.hardLimits) { + switch (lim.operation_type) { + case "DEPOSIT": + case "AGGREGATE": + // FIXME: This should consider past deposits and KYC checks + kycHardLimit = minDefAmount(kycHardLimit, lim.threshold); + break; + } + } + + for (let limAmount of exchange.walletBalanceLimitWithoutKyc ?? []) { + kycSoftLimit = minDefAmount(kycSoftLimit, limAmount); + } + + for (let lim of exchange.zeroLimits) { + switch (lim.operation_type) { + case "DEPOSIT": + case "AGGREGATE": + kycSoftLimit = Amounts.zeroOfAmount(instructedAmount); + break; + } + } + + return { + kycHardLimit: kycHardLimit ? Amounts.stringify(kycHardLimit) : undefined, + kycSoftLimit: kycSoftLimit ? Amounts.stringify(kycSoftLimit) : undefined, + }; +} + +export function getPeerCreditLimitInfo( + exchange: ReadyExchangeSummary, + instructedAmount: AmountLike, +): SimpleLimitInfo { + let kycHardLimit: AmountJson | undefined; + let kycSoftLimit: AmountJson | undefined; + + for (let lim of exchange.hardLimits) { + switch (lim.operation_type) { + case "BALANCE": + case "MERGE": + // FIXME: This should consider past merges and KYC checks + kycHardLimit = minDefAmount(kycHardLimit, lim.threshold); + break; + } + } + + for (let limAmount of exchange.walletBalanceLimitWithoutKyc ?? []) { + kycSoftLimit = minDefAmount(kycSoftLimit, limAmount); + } + + for (let lim of exchange.zeroLimits) { + switch (lim.operation_type) { + case "BALANCE": + case "MERGE": + kycSoftLimit = Amounts.zeroOfAmount(instructedAmount); + break; + } + } + + return { + kycHardLimit: kycHardLimit ? Amounts.stringify(kycHardLimit) : undefined, + kycSoftLimit: kycSoftLimit ? Amounts.stringify(kycSoftLimit) : undefined, + }; +} + +export function checkWithdrawalHardLimitExceeded( + exchange: ReadyExchangeSummary, + instructedAmount: AmountLike, +): boolean { + const limitInfo = getWithdrawalLimitInfo(exchange, instructedAmount); + return ( + limitInfo.kycHardLimit != null && + Amounts.cmp(limitInfo.kycHardLimit, instructedAmount) <= 0 + ); +} + +export function checkPeerCreditHardLimitExceeded( + exchange: ReadyExchangeSummary, + instructedAmount: AmountLike, +): boolean { + const limitInfo = getPeerCreditLimitInfo(exchange, instructedAmount); + return ( + limitInfo.kycHardLimit != null && + Amounts.cmp(limitInfo.kycHardLimit, instructedAmount) <= 0 + ); +} + +export function checkDepositHardLimitExceeded( + exchanges: ReadyExchangeSummary[], + instructedAmount: AmountLike, +): boolean { + const limitInfo = getDepositLimitInfo(exchanges, instructedAmount); + return ( + limitInfo.kycHardLimit != null && + Amounts.cmp(limitInfo.kycHardLimit, instructedAmount) <= 0 + ); +} + +export function getWithdrawalLimitInfo( + exchange: ReadyExchangeSummary, + instructedAmount: AmountLike, +): SimpleLimitInfo { + let kycHardLimit: AmountJson | undefined; + let kycSoftLimit: AmountJson | undefined; + + for (let lim of exchange.hardLimits) { + switch (lim.operation_type) { + case "BALANCE": + case "WITHDRAW": + // FIXME: This should consider past withdrawals and KYC checks + kycHardLimit = minDefAmount(kycHardLimit, lim.threshold); + break; + } + } + + for (let limAmount of exchange.walletBalanceLimitWithoutKyc ?? []) { + kycSoftLimit = minDefAmount(kycSoftLimit, limAmount); + } + + for (let lim of exchange.zeroLimits) { + switch (lim.operation_type) { + case "BALANCE": + case "WITHDRAW": + kycSoftLimit = Amounts.zeroOfAmount(instructedAmount); + break; + } + } + + return { + kycHardLimit: kycHardLimit ? Amounts.stringify(kycHardLimit) : undefined, + kycSoftLimit: kycSoftLimit ? Amounts.stringify(kycSoftLimit) : undefined, + }; +} diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts index e0b1fad98..f48a2f2fe 100644 --- a/packages/taler-wallet-core/src/pay-merchant.ts +++ b/packages/taler-wallet-core/src/pay-merchant.ts @@ -32,7 +32,6 @@ import { Amounts, AmountString, assertUnreachable, - AsyncFlag, checkDbInvariant, CheckPayTemplateReponse, CheckPayTemplateRequest, @@ -58,6 +57,7 @@ import { Logger, makeErrorDetail, makePendingOperationFailedError, + makeTalerErrorDetail, MerchantCoinRefundStatus, MerchantContractTerms, MerchantPayResponse, @@ -113,6 +113,8 @@ import { } from "./coinSelection.js"; import { constructTaskIdentifier, + genericWaitForState, + genericWaitForStateVal, PendingTaskType, spendCoins, TaskIdentifiers, @@ -123,7 +125,7 @@ import { TransactionContext, TransitionResultType, } from "./common.js"; -import { EddsaKeypair } from "./crypto/cryptoImplementation.js"; +import { EddsaKeyPairStrings } from "./crypto/cryptoImplementation.js"; import { CoinRecord, DbCoinSelection, @@ -302,6 +304,7 @@ export class PayMerchantTransactionContext implements TransactionContext { proposalId: purchaseRec.proposalId, }), proposalId: purchaseRec.proposalId, + abortReason: purchaseRec.abortReason, info, refundQueryActive: purchaseRec.purchaseStatus === PurchaseStatus.PendingQueryingRefund, @@ -422,7 +425,7 @@ export class PayMerchantTransactionContext implements TransactionContext { notifyTransition(wex, transactionId, transitionInfo); } - async abortTransaction(): Promise<void> { + async abortTransaction(reason?: TalerErrorDetail): Promise<void> { const { wex, proposalId, transactionId } = this; const transitionInfo = await wex.db.runReadWriteTx( { @@ -450,6 +453,7 @@ export class PayMerchantTransactionContext implements TransactionContext { return; case PurchaseStatus.PendingPaying: case PurchaseStatus.SuspendedPaying: { + purchase.abortReason = reason; purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund; if (purchase.payInfo && purchase.payInfo.payCoinSelection) { const coinSel = purchase.payInfo.payCoinSelection; @@ -526,7 +530,7 @@ export class PayMerchantTransactionContext implements TransactionContext { wex.taskScheduler.startShepherdTask(this.taskId); } - async failTransaction(): Promise<void> { + async failTransaction(reason?: TalerErrorDetail): Promise<void> { const { wex, proposalId, transactionId } = this; const transitionInfo = await wex.db.runReadWriteTx( { @@ -554,6 +558,7 @@ export class PayMerchantTransactionContext implements TransactionContext { } if (newState) { purchase.purchaseStatus = newState; + purchase.failReason = reason; await tx.purchases.put(purchase); } await this.updateTransactionMeta(tx); @@ -1076,22 +1081,29 @@ async function processDownloadProposal( fulfillmentUrl && (fulfillmentUrl.startsWith("http://") || fulfillmentUrl.startsWith("https://")); - let otherPurchase: PurchaseRecord | undefined; + let repurchase: PurchaseRecord | undefined = undefined; + const otherPurchases = + await tx.purchases.indexes.byFulfillmentUrl.getAll(fulfillmentUrl); if (isResourceFulfillmentUrl) { - otherPurchase = - await tx.purchases.indexes.byFulfillmentUrl.get(fulfillmentUrl); + for (const otherPurchase of otherPurchases) { + if ( + otherPurchase.purchaseStatus == PurchaseStatus.Done || + otherPurchase.purchaseStatus == PurchaseStatus.PendingPaying || + otherPurchase.purchaseStatus == PurchaseStatus.PendingPayingReplay + ) { + repurchase = otherPurchase; + break; + } + } } + // FIXME: Adjust this to account for refunds, don't count as repurchase // if original order is refunded. - if ( - otherPurchase && - (otherPurchase.purchaseStatus == PurchaseStatus.Done || - otherPurchase.purchaseStatus == PurchaseStatus.PendingPaying || - otherPurchase.purchaseStatus == PurchaseStatus.PendingPayingReplay) - ) { + + if (repurchase) { logger.warn("repurchase detected"); p.purchaseStatus = PurchaseStatus.DoneRepurchaseDetected; - p.repurchaseProposalId = otherPurchase.proposalId; + p.repurchaseProposalId = repurchase.proposalId; await tx.purchases.put(p); } else { p.purchaseStatus = p.shared @@ -1198,7 +1210,7 @@ async function createOrReusePurchase( return oldProposal.proposalId; } - let noncePair: EddsaKeypair; + let noncePair: EddsaKeyPairStrings; let shared = false; if (noncePriv) { shared = true; @@ -1213,6 +1225,10 @@ async function createOrReusePurchase( const { priv, pub } = noncePair; const proposalId = encodeCrock(getRandomBytes(32)); + logger.info( + `created new proposal for ${orderId} at ${merchantBaseUrl} session ${sessionId}`, + ); + const proposalRecord: PurchaseRecord = { download: undefined, noncePriv: priv, @@ -1605,6 +1621,10 @@ async function checkPaymentByProposalId( let coins: SelectedProspectiveCoin[] | undefined = undefined; + const allowedExchangeUrls = contractData.allowedExchanges.map( + (x) => x.exchangeBaseUrl, + ); + switch (res.type) { case "failure": { logger.info("not allowing payment, insufficient coins"); @@ -1613,12 +1633,15 @@ async function checkPaymentByProposalId( res.insufficientBalanceDetails, )}`, ); + let scopes = await wex.db.runAllStoresReadOnlyTx({}, async (tx) => { + return getScopeForAllExchanges(tx, allowedExchangeUrls); + }); return { status: PreparePayResultType.InsufficientBalance, contractTerms: d.contractTermsRaw, - proposalId: proposal.proposalId, transactionId, amountRaw: Amounts.stringify(d.contractData.amount), + scopes, talerUri, balanceDetails: res.insufficientBalanceDetails, }; @@ -1637,21 +1660,34 @@ async function checkPaymentByProposalId( logger.trace("costInfo", totalCost); logger.trace("coinsForPayment", res); + const exchanges = new Set<string>(coins.map((x) => x.exchangeBaseUrl)); + + const scopes = await wex.db.runAllStoresReadOnlyTx({}, async (tx) => { + return await getScopeForAllExchanges(tx, [...exchanges]); + }); + return { status: PreparePayResultType.PaymentPossible, contractTerms: d.contractTermsRaw, transactionId, - proposalId: proposal.proposalId, amountEffective: Amounts.stringify(totalCost), amountRaw: Amounts.stringify(instructedAmount), + scopes, contractTermsHash: d.contractData.contractTermsHash, talerUri, }; } + const scopes = await wex.db.runAllStoresReadOnlyTx({}, async (tx) => { + let exchangeUrls = contractData.allowedExchanges.map( + (x) => x.exchangeBaseUrl, + ); + return await getScopeForAllExchanges(tx, exchangeUrls); + }); + if ( - purchase.purchaseStatus === PurchaseStatus.Done && - purchase.lastSessionId !== sessionId + purchase.purchaseStatus === PurchaseStatus.Done || + purchase.purchaseStatus === PurchaseStatus.PendingPayingReplay ) { logger.trace( "automatically re-submitting payment with different session ID", @@ -1690,8 +1726,8 @@ async function checkPaymentByProposalId( amountEffective: purchase.payInfo ? Amounts.stringify(purchase.payInfo.totalPayCost) : undefined, + scopes, transactionId, - proposalId, talerUri, }; } else if (!purchase.timestampFirstSuccessfulPay) { @@ -1705,16 +1741,12 @@ async function checkPaymentByProposalId( amountEffective: purchase.payInfo ? Amounts.stringify(purchase.payInfo.totalPayCost) : undefined, + scopes, transactionId, - proposalId, talerUri, }; } else { - const paid = - purchase.purchaseStatus === PurchaseStatus.Done || - purchase.purchaseStatus === PurchaseStatus.PendingQueryingRefund || - purchase.purchaseStatus === PurchaseStatus.FinalizingQueryingAutoRefund || - purchase.purchaseStatus === PurchaseStatus.PendingQueryingAutoRefund; + const paid = isPurchasePaid(purchase); const download = await expectProposalDownload(wex, purchase); return { status: PreparePayResultType.AlreadyConfirmed, @@ -1726,13 +1758,22 @@ async function checkPaymentByProposalId( ? Amounts.stringify(purchase.payInfo.totalPayCost) : undefined, ...(paid ? { nextUrl: download.contractData.orderId } : {}), + scopes, transactionId, - proposalId, talerUri, }; } } +function isPurchasePaid(purchase: PurchaseRecord): boolean { + return ( + purchase.purchaseStatus === PurchaseStatus.Done || + purchase.purchaseStatus === PurchaseStatus.PendingQueryingRefund || + purchase.purchaseStatus === PurchaseStatus.FinalizingQueryingAutoRefund || + purchase.purchaseStatus === PurchaseStatus.PendingQueryingAutoRefund + ); +} + export async function getContractTermsDetails( wex: WalletExecutionContext, proposalId: string, @@ -1803,59 +1844,39 @@ async function waitProposalDownloaded( wex.taskScheduler.startShepherdTask(ctx.taskId); - // FIXME: We should use Symbol.dispose magic here for cleanup! - - const payNotifFlag = new AsyncFlag(); - // Raise exchangeNotifFlag whenever we get a notification - // about our exchange. - const cancelNotif = wex.ws.addNotificationListener((notif) => { - if ( - notif.type === NotificationType.TransactionStateTransition && - notif.transactionId === ctx.transactionId - ) { - logger.info(`raising update notification: ${j2s(notif)}`); - payNotifFlag.raise(); - } - }); - - try { - await internalWaitProposalDownloaded(ctx, payNotifFlag); - logger.info(`done waiting for ${ctx.transactionId} to be downloaded`); - } finally { - cancelNotif(); - } -} - -async function internalWaitProposalDownloaded( - ctx: PayMerchantTransactionContext, - payNotifFlag: AsyncFlag, -): Promise<void> { - while (true) { - const { purchase, retryInfo } = await ctx.wex.db.runReadOnlyTx( - { storeNames: ["purchases", "operationRetries"] }, - async (tx) => { - return { - purchase: await tx.purchases.get(ctx.proposalId), - retryInfo: await tx.operationRetries.get(ctx.taskId), - }; - }, - ); - if (!purchase) { - throw Error("purchase does not exist anymore"); - } - if (purchase.download) { - return; - } - if (retryInfo) { - if (retryInfo.lastError) { - throw TalerError.fromUncheckedDetail(retryInfo.lastError); - } else { - throw Error("transient error while waiting for proposal download"); + await genericWaitForState(wex, { + filterNotification(notif) { + return ( + notif.type === NotificationType.TransactionStateTransition && + notif.transactionId === ctx.transactionId + ); + }, + async checkState() { + const { purchase, retryInfo } = await ctx.wex.db.runReadOnlyTx( + { storeNames: ["purchases", "operationRetries"] }, + async (tx) => { + return { + purchase: await tx.purchases.get(ctx.proposalId), + retryInfo: await tx.operationRetries.get(ctx.taskId), + }; + }, + ); + if (!purchase) { + throw Error("purchase does not exist anymore"); } - } - await payNotifFlag.wait(); - payNotifFlag.reset(); - } + if (purchase.download) { + return true; + } + if (retryInfo) { + if (retryInfo.lastError) { + throw TalerError.fromUncheckedDetail(retryInfo.lastError); + } else { + throw Error("transient error while waiting for proposal download"); + } + } + return false; + }, + }); } async function downloadTemplate( @@ -1896,7 +1917,13 @@ export async function checkPayForTemplate( const cfg = await merchantApi.getConfig(); if (cfg.type === "fail") { - throw TalerError.fromUncheckedDetail(cfg.detail); + if (cfg.detail) { + throw TalerError.fromUncheckedDetail(cfg.detail); + } else { + throw TalerError.fromException( + new Error("failed to get merchant remote config"), + ); + } } // FIXME: Put body.currencies *and* body.currency in the set of @@ -2033,108 +2060,80 @@ export async function generateDepositPermissions( return depositPermissions; } -async function internalWaitPaymentResult( - ctx: PayMerchantTransactionContext, - purchaseNotifFlag: AsyncFlag, +/** + * Wait until either: + * a) the payment succeeded (if provided under the {@param waitSessionId}), or + * b) the attempt to pay failed (merchant unavailable, etc.) + */ +async function waitPaymentResult( + wex: WalletExecutionContext, + proposalId: string, waitSessionId?: string, ): Promise<ConfirmPayResult> { - while (true) { - const txRes = await ctx.wex.db.runReadOnlyTx( - { storeNames: ["purchases", "operationRetries"] }, - async (tx) => { - const purchase = await tx.purchases.get(ctx.proposalId); - const retryRecord = await tx.operationRetries.get(ctx.taskId); - return { purchase, retryRecord }; - }, - ); + const ctx = new PayMerchantTransactionContext(wex, proposalId); + wex.taskScheduler.startShepherdTask(ctx.taskId); - if (!txRes.purchase) { - throw Error("purchase gone"); - } + return await genericWaitForStateVal<ConfirmPayResult>(wex, { + filterNotification(notif) { + return ( + notif.type === NotificationType.TransactionStateTransition && + notif.transactionId === ctx.transactionId + ); + }, + async checkState() { + const txRes = await ctx.wex.db.runReadOnlyTx( + { storeNames: ["purchases", "operationRetries"] }, + async (tx) => { + const purchase = await tx.purchases.get(ctx.proposalId); + const retryRecord = await tx.operationRetries.get(ctx.taskId); + return { purchase, retryRecord }; + }, + ); + + if (!txRes.purchase) { + throw Error("purchase gone"); + } - const purchase = txRes.purchase; + const purchase = txRes.purchase; - logger.info( - `purchase is in state ${PurchaseStatus[purchase.purchaseStatus]}`, - ); + logger.info( + `purchase is in state ${PurchaseStatus[purchase.purchaseStatus]}`, + ); - const d = await expectProposalDownload(ctx.wex, purchase); + const d = await expectProposalDownload(ctx.wex, purchase); - if (txRes.purchase.timestampFirstSuccessfulPay) { - if ( - waitSessionId == null || - txRes.purchase.lastSessionId === waitSessionId - ) { + if (txRes.purchase.timestampFirstSuccessfulPay) { + if ( + waitSessionId == null || + txRes.purchase.lastSessionId === waitSessionId + ) { + return { + type: ConfirmPayResultType.Done, + contractTerms: d.contractTermsRaw, + transactionId: ctx.transactionId, + }; + } + } + + if (txRes.retryRecord) { + return { + type: ConfirmPayResultType.Pending, + lastError: txRes.retryRecord.lastError, + transactionId: ctx.transactionId, + }; + } + + if (txRes.purchase.purchaseStatus >= PurchaseStatus.Done) { return { type: ConfirmPayResultType.Done, contractTerms: d.contractTermsRaw, transactionId: ctx.transactionId, }; } - } - - if (txRes.retryRecord) { - return { - type: ConfirmPayResultType.Pending, - lastError: txRes.retryRecord.lastError, - transactionId: ctx.transactionId, - }; - } - - if (txRes.purchase.purchaseStatus >= PurchaseStatus.Done) { - return { - type: ConfirmPayResultType.Done, - contractTerms: d.contractTermsRaw, - transactionId: ctx.transactionId, - }; - } - - await purchaseNotifFlag.wait(); - purchaseNotifFlag.reset(); - } -} - -/** - * Wait until either: - * a) the payment succeeded (if provided under the {@param waitSessionId}), or - * b) the attempt to pay failed (merchant unavailable, etc.) - */ -async function waitPaymentResult( - wex: WalletExecutionContext, - proposalId: string, - waitSessionId?: string, -): Promise<ConfirmPayResult> { - // FIXME: We don't support cancelletion yet! - const ctx = new PayMerchantTransactionContext(wex, proposalId); - wex.taskScheduler.startShepherdTask(ctx.taskId); - // FIXME: Clean up using the new JS "using" / Symbol.dispose syntax. - const purchaseNotifFlag = new AsyncFlag(); - // Raise purchaseNotifFlag whenever we get a notification - // about our purchase. - const cancelNotif = wex.ws.addNotificationListener((notif) => { - if ( - notif.type === NotificationType.TransactionStateTransition && - notif.transactionId === ctx.transactionId - ) { - purchaseNotifFlag.raise(); - } + return undefined; + }, }); - - try { - logger.info(`waiting for first payment success on ${ctx.transactionId}`); - const res = await internalWaitPaymentResult( - ctx, - purchaseNotifFlag, - waitSessionId, - ); - logger.info( - `done waiting for first payment success on ${ctx.transactionId}, result ${res.type}`, - ); - return res; - } finally { - cancelNotif(); - } } /** @@ -2327,10 +2326,6 @@ export async function confirmPay( ); notifyTransition(wex, transactionId, transitionInfo); - wex.ws.notify({ - type: NotificationType.BalanceChange, - hintTransactionId: transactionId, - }); // In case we're sharing the payment and we're long-polling wex.taskScheduler.stopShepherdTask(ctx.taskId); @@ -2657,6 +2652,16 @@ async function processPurchasePay( } } + if (resp.status === HttpStatusCode.UnavailableForLegalReasons) { + logger.warn(`pay transaction aborted, merchant has KYC problems`); + await ctx.abortTransaction( + makeTalerErrorDetail(TalerErrorCode.WALLET_PAY_MERCHANT_KYC_MISSING, { + exchangeResponse: await resp.json(), + }), + ); + return TaskRunResult.progress(); + } + if (resp.status >= 400 && resp.status <= 499) { logger.trace("got generic 4xx from merchant"); const err = await readTalerErrorResponse(resp); diff --git a/packages/taler-wallet-core/src/pay-peer-common.ts b/packages/taler-wallet-core/src/pay-peer-common.ts index 636dd4156..b9b637c57 100644 --- a/packages/taler-wallet-core/src/pay-peer-common.ts +++ b/packages/taler-wallet-core/src/pay-peer-common.ts @@ -71,6 +71,7 @@ export async function queryCoinInfosForSelection( denomSig: coin.denomSig, ageCommitmentProof: coin.ageCommitmentProof, contribution: csel.contributions[i], + feeDeposit: denom.feeDeposit, }); } }, diff --git a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts index 45ca93a40..a92d8e764 100644 --- 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,7 +30,10 @@ import { Logger, NotificationType, PeerContractTerms, + ScopeInfo, + ScopeType, TalerErrorCode, + TalerErrorDetail, TalerPreciseTimestamp, TalerProtocolTimestamp, TalerUriAction, @@ -43,11 +45,11 @@ import { TransactionState, TransactionType, WalletAccountMergeFlags, - WalletKycUuid, assertUnreachable, checkDbInvariant, + codecForAccountKycStatus, codecForAny, - codecForWalletKycUuid, + codecForLegitimizationNeededResponse, encodeCrock, getRandomBytes, j2s, @@ -55,7 +57,10 @@ import { stringifyTalerUri, talerPaytoFromExchangeReserve, } from "@gnu-taler/taler-util"; -import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; +import { + readResponseJsonOrThrow, + readSuccessResponseJsonOrThrow, +} from "@gnu-taler/taler-util/http"; import { PendingTaskType, TaskIdStr, @@ -71,7 +76,6 @@ import { runWithClientCancellation, } from "./common.js"; import { - KycPendingInfo, OperationRetryRecord, PeerPullCreditRecord, PeerPullPaymentCreditStatus, @@ -81,7 +85,6 @@ import { WithdrawalGroupRecord, WithdrawalGroupStatus, WithdrawalRecordType, - timestampOptionalPreciseFromDb, timestampPreciseFromDb, timestampPreciseToDb, } from "./db.js"; @@ -89,9 +92,12 @@ import { BalanceThresholdCheckResult, checkIncomingAmountLegalUnderKycBalanceThreshold, fetchFreshExchange, + getPreferredExchangeForCurrency, + getPreferredExchangeForScope, getScopeForAllExchanges, handleStartExchangeWalletKyc, } from "./exchanges.js"; +import { checkPeerCreditHardLimitExceeded } from "./kyc.js"; import { codecForExchangePurseStatus, getMergeReserveInfo, @@ -262,6 +268,14 @@ export class PeerPullCreditTransactionContext implements TransactionContext { TaskIdentifiers.forPeerPullPaymentInitiation(pullCredit); let pullCreditOrt = await tx.operationRetries.get(pullCreditOpId); + let kycUrl: string | undefined = undefined; + if (pullCredit.kycPaytoHash) { + kycUrl = new URL( + `kyc-spa/${pullCredit.kycPaytoHash}`, + pullCredit.exchangeBaseUrl, + ).href; + } + if (wsr) { if (wsr.wgInfo.withdrawalType !== WithdrawalRecordType.PeerPullCredit) { throw Error(`Unexpected withdrawalType: ${wsr.wgInfo.withdrawalType}`); @@ -308,7 +322,10 @@ export class PeerPullCreditTransactionContext implements TransactionContext { tag: TransactionType.PeerPullCredit, pursePub: pullCredit.pursePub, }), - kycUrl: pullCredit.kycUrl, + abortReason: pullCredit.abortReason, + failReason: pullCredit.failReason, + // FIXME: Is this the KYC URL of the withdrawal group?! + kycUrl: kycUrl, ...(wsrOrt?.lastError ? { error: silentWithdrawalErrorForInvoice @@ -343,7 +360,11 @@ export class PeerPullCreditTransactionContext implements TransactionContext { tag: TransactionType.PeerPullCredit, pursePub: pullCredit.pursePub, }), - kycUrl: pullCredit.kycUrl, + kycUrl, + kycAccessToken: pullCredit.kycAccessToken, + kycPaytoHash: pullCredit.kycPaytoHash, + abortReason: pullCredit.abortReason, + failReason: pullCredit.failReason, ...(pullCreditOrt?.lastError ? { error: pullCreditOrt.lastError } : {}), }; } @@ -455,7 +476,7 @@ export class PeerPullCreditTransactionContext implements TransactionContext { notifyTransition(wex, transactionId, transitionInfo); } - async failTransaction(): Promise<void> { + async failTransaction(reason?: TalerErrorDetail): Promise<void> { const { wex, pursePub, taskId: retryTag, transactionId } = this; const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["peerPullCredit", "transactionsMeta"] }, @@ -495,6 +516,7 @@ export class PeerPullCreditTransactionContext implements TransactionContext { const oldTxState = computePeerPullCreditTransactionState(pullCreditRec); pullCreditRec.status = newStatus; + pullCreditRec.failReason = reason; const newTxState = computePeerPullCreditTransactionState(pullCreditRec); await tx.peerPullCredit.put(pullCreditRec); @@ -579,7 +601,7 @@ export class PeerPullCreditTransactionContext implements TransactionContext { wex.taskScheduler.startShepherdTask(retryTag); } - async abortTransaction(): Promise<void> { + async abortTransaction(reason?: TalerErrorDetail): Promise<void> { const { wex, pursePub, taskId: retryTag, transactionId } = this; const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["peerPullCredit", "transactionsMeta"] }, @@ -598,10 +620,12 @@ export class PeerPullCreditTransactionContext implements TransactionContext { case PeerPullPaymentCreditStatus.PendingCreatePurse: case PeerPullPaymentCreditStatus.PendingMergeKycRequired: newStatus = PeerPullPaymentCreditStatus.AbortingDeletePurse; + pullCreditRec.abortReason = reason; break; case PeerPullPaymentCreditStatus.PendingWithdrawing: throw Error("can't abort anymore"); case PeerPullPaymentCreditStatus.PendingReady: + pullCreditRec.abortReason = reason; newStatus = PeerPullPaymentCreditStatus.AbortingDeletePurse; break; case PeerPullPaymentCreditStatus.Done: @@ -758,7 +782,7 @@ async function longpollKycStatus( wex: WalletExecutionContext, pursePub: string, exchangeUrl: string, - kycInfo: KycPendingInfo, + kycPaytoHash: string, ): Promise<TaskRunResult> { // FIXME: What if this changes? Should be part of the p2p record const mergeReserveInfo = await getMergeReserveInfo(wex, { @@ -771,7 +795,7 @@ async function longpollKycStatus( }); const ctx = new PeerPullCreditTransactionContext(wex, pursePub); - const url = new URL(`kyc-check/${kycInfo.requirementRow}`, exchangeUrl); + const url = new URL(`kyc-check/${kycPaytoHash}`, exchangeUrl); const kycStatusRes = await wex.ws.runLongpollQueueing( wex, url.hostname, @@ -1049,9 +1073,9 @@ async function handlePeerPullCreditCreatePurse( if (httpResp.status === HttpStatusCode.UnavailableForLegalReasons) { const respJson = await httpResp.json(); - const kycPending = codecForWalletKycUuid().decode(respJson); + const kycPending = codecForLegitimizationNeededResponse().decode(respJson); logger.info(`kyc uuid response: ${j2s(kycPending)}`); - return processPeerPullCreditKycRequired(wex, pullIni, kycPending); + return processPeerPullCreditKycRequired(wex, pullIni, kycPending.h_payto); } const resp = await readSuccessResponseJsonOrThrow(httpResp, codecForAny()); @@ -1111,14 +1135,14 @@ export async function processPeerPullCredit( case PeerPullPaymentCreditStatus.PendingReady: return queryPurseForPeerPullCredit(wex, pullIni); case PeerPullPaymentCreditStatus.PendingMergeKycRequired: { - if (!pullIni.kycInfo) { - throw Error("invalid state, kycInfo required"); + if (!pullIni.kycPaytoHash) { + throw Error("invalid state, kycPaytoHash required"); } return await longpollKycStatus( wex, pursePub, pullIni.exchangeBaseUrl, - pullIni.kycInfo, + pullIni.kycPaytoHash, ); } case PeerPullPaymentCreditStatus.PendingCreatePurse: @@ -1217,13 +1241,7 @@ async function processPeerPullCreditBalanceKyc( return TransitionResult.stay(); } rec.status = PeerPullPaymentCreditStatus.PendingBalanceKycRequired; - delete rec.kycInfo; rec.kycAccessToken = ret.walletKycAccessToken; - // FIXME: #9109 this should not be constructed here, it should be an opaque URL from exchange response - rec.kycUrl = new URL( - `kyc-spa/${ret.walletKycAccessToken}`, - exchangeBaseUrl, - ).href; return TransitionResult.transition(rec); }); return TaskRunResult.progress(); @@ -1235,7 +1253,7 @@ async function processPeerPullCreditBalanceKyc( async function processPeerPullCreditKycRequired( wex: WalletExecutionContext, peerIni: PeerPullCreditRecord, - kycPending: WalletKycUuid, + kycPayoHash: string, ): Promise<TaskRunResult> { const ctx = new PeerPullCreditTransactionContext(wex, peerIni.pursePub); const { pursePub } = peerIni; @@ -1250,10 +1268,7 @@ async function processPeerPullCreditKycRequired( accountPub: mergeReserveInfo.reservePub, }); - const url = new URL( - `kyc-check/${kycPending.requirement_row}`, - peerIni.exchangeBaseUrl, - ); + const url = new URL(`kyc-check/${kycPayoHash}`, peerIni.exchangeBaseUrl); logger.info(`kyc url ${url.href}`); const kycStatusRes = await wex.http.fetch(url.href, { @@ -1271,7 +1286,10 @@ async function processPeerPullCreditKycRequired( logger.warn("kyc requested, but already fulfilled"); return TaskRunResult.backoff(); } else if (kycStatusRes.status === HttpStatusCode.Accepted) { - const kycStatus = await kycStatusRes.json(); + const kycStatus = await readResponseJsonOrThrow( + kycStatusRes, + codecForAccountKycStatus(), + ); logger.info(`kyc status: ${j2s(kycStatus)}`); const { transitionInfo, result } = await wex.db.runReadWriteTx( { storeNames: ["peerPullCredit", "transactionsMeta"] }, @@ -1284,11 +1302,11 @@ async function processPeerPullCreditKycRequired( }; } const oldTxState = computePeerPullCreditTransactionState(peerInc); - peerInc.kycInfo = { - paytoHash: kycPending.h_payto, - requirementRow: kycPending.requirement_row, - }; - peerInc.kycUrl = kycStatus.kyc_url; + peerInc.kycPaytoHash = kycPayoHash; + logger.info( + `setting peer-pull-credit kyc payto hash to ${kycPayoHash}`, + ); + peerInc.kycAccessToken = kycStatus.access_token; peerInc.status = PeerPullPaymentCreditStatus.PendingMergeKycRequired; const newTxState = computePeerPullCreditTransactionState(peerInc); await tx.peerPullCredit.put(peerInc); @@ -1300,7 +1318,7 @@ async function processPeerPullCreditKycRequired( }, ); notifyTransition(wex, ctx.transactionId, transitionInfo); - return TaskRunResult.backoff(); + return result; } else { throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`); } @@ -1330,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); } @@ -1376,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( @@ -1450,6 +1437,12 @@ export async function initiatePeerPullPayment( const exchange = await fetchFreshExchange(wex, exchangeBaseUrl); requireExchangeTosAcceptedOrThrow(exchange); + if ( + checkPeerCreditHardLimitExceeded(exchange, req.partialContractTerms.amount) + ) { + throw Error("peer credit would exceed hard KYC limit"); + } + const mergeReserveInfo = await getMergeReserveInfo(wex, { exchangeBaseUrl: exchangeBaseUrl, }); @@ -1526,12 +1519,6 @@ export async function initiatePeerPullPayment( notifyTransition(wex, ctx.transactionId, transitionInfo); wex.taskScheduler.startShepherdTask(ctx.taskId); - // The pending-incoming balance has changed. - wex.ws.notify({ - type: NotificationType.BalanceChange, - hintTransactionId: ctx.transactionId, - }); - return { talerUri: stringifyTalerUri({ type: TalerUriAction.PayPull, diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts index 09ecbeb94..9c6c696dd 100644 --- a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts +++ b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts @@ -32,7 +32,6 @@ import { ExchangePurseDeposits, HttpStatusCode, Logger, - NotificationType, ObservabilityEventType, PeerContractTerms, PreparePeerPullDebitRequest, @@ -41,6 +40,7 @@ import { SelectedProspectiveCoin, TalerError, TalerErrorCode, + TalerErrorDetail, TalerPreciseTimestamp, TalerProtocolViolationError, Transaction, @@ -61,6 +61,7 @@ import { encodeCrock, getRandomBytes, j2s, + makeTalerErrorDetail, parsePayPullUri, } from "@gnu-taler/taler-util"; import { @@ -89,6 +90,7 @@ import { timestampPreciseFromDb, timestampPreciseToDb, } from "./db.js"; +import { getExchangeScopeInfo, getScopeForAllExchanges } from "./exchanges.js"; import { codecForExchangePurseStatus, getTotalPeerPaymentCost, @@ -103,7 +105,6 @@ import { parseTransactionIdentifier, } from "./transactions.js"; import { WalletExecutionContext } from "./wallet.js"; -import { getScopeForAllExchanges } from "./exchanges.js"; const logger = new Logger("pay-peer-pull-debit.ts"); @@ -180,6 +181,8 @@ export class PeerPullDebitTransactionContext implements TransactionContext { expiration: contractTerms.purse_expiration, summary: contractTerms.summary, }, + abortReason: pi.abortReason, + failReason: pi.failReason, timestamp: timestampPreciseFromDb(pi.timestampCreated), transactionId: constructTransactionIdentifier({ tag: TransactionType.PeerPullDebit, @@ -282,7 +285,7 @@ export class PeerPullDebitTransactionContext implements TransactionContext { this.wex.taskScheduler.startShepherdTask(this.taskId); } - async failTransaction(): Promise<void> { + async failTransaction(reason?: TalerErrorDetail): Promise<void> { const ctx = this; await ctx.transition(async (pi) => { switch (pi.status) { @@ -292,6 +295,7 @@ export class PeerPullDebitTransactionContext implements TransactionContext { case PeerPullDebitRecordStatus.SuspendedAbortingRefresh: // FIXME: Should we also abort the corresponding refresh session?! pi.status = PeerPullDebitRecordStatus.Failed; + pi.failReason = reason; return TransitionResultType.Transition; default: return TransitionResultType.Stay; @@ -300,7 +304,7 @@ export class PeerPullDebitTransactionContext implements TransactionContext { this.wex.taskScheduler.stopShepherdTask(this.taskId); } - async abortTransaction(): Promise<void> { + async abortTransaction(reason?: TalerErrorDetail): Promise<void> { const ctx = this; await ctx.transitionExtra( { @@ -347,6 +351,7 @@ export class PeerPullDebitTransactionContext implements TransactionContext { pi.status = PeerPullDebitRecordStatus.AbortingRefresh; pi.abortRefreshGroupId = refresh.refreshGroupId; + pi.abortReason = reason; return TransitionResultType.Transition; }, ); @@ -657,7 +662,12 @@ async function processPeerPullDebitPendingDeposit( continue; } case HttpStatusCode.Gone: { - await ctx.abortTransaction(); + await ctx.abortTransaction( + makeTalerErrorDetail( + TalerErrorCode.WALLET_PEER_PULL_DEBIT_PURSE_GONE, + {}, + ), + ); return TaskRunResult.backoff(); } case HttpStatusCode.Conflict: { @@ -817,9 +827,7 @@ export async function confirmPeerPullDebit( const totalAmount = await getTotalPeerPaymentCost(wex, coins); - // FIXME: Missing notification here! - - await wex.db.runReadWriteTx( + const transitionInfo = await wex.db.runReadWriteTx( { storeNames: [ "coinAvailability", @@ -841,6 +849,7 @@ export async function confirmPeerPullDebit( if (pi.status !== PeerPullDebitRecordStatus.DialogProposed) { return; } + const oldTxState = computePeerPullDebitTransactionState(pi); if (coinSelRes.type == "success") { await spendCoins(wex, tx, { transactionId, @@ -859,13 +868,12 @@ export async function confirmPeerPullDebit( pi.status = PeerPullDebitRecordStatus.PendingDeposit; await ctx.updateTransactionMeta(tx); await tx.peerPullDebit.put(pi); + const newTxState = computePeerPullDebitTransactionState(pi); + return { oldTxState, newTxState }; }, ); - wex.ws.notify({ - type: NotificationType.BalanceChange, - hintTransactionId: transactionId, - }); + notifyTransition(wex, transactionId, transitionInfo); wex.taskScheduler.startShepherdTask(ctx.taskId); @@ -889,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([ @@ -905,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, + }; }, ); @@ -915,7 +943,8 @@ export async function preparePeerPullDebit( amountRaw: existing.peerPullDebitRecord.amount, 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, @@ -1000,6 +1029,7 @@ export async function preparePeerPullDebit( } const totalAmount = await getTotalPeerPaymentCost(wex, coins); + const currency = Amounts.currencyOf(totalAmount); const ctx = new PeerPullDebitTransactionContext(wex, peerPullDebitId); @@ -1025,16 +1055,18 @@ 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 index 7c131c45a..c84d79945 100644 --- a/packages/taler-wallet-core/src/pay-peer-push-credit.ts +++ b/packages/taler-wallet-core/src/pay-peer-push-credit.ts @@ -23,11 +23,13 @@ import { ExchangePurseMergeRequest, ExchangeWalletKycStatus, HttpStatusCode, + LegitimizationNeededResponse, Logger, NotificationType, PeerContractTerms, PreparePeerPushCreditRequest, PreparePeerPushCreditResponse, + TalerErrorDetail, TalerPreciseTimestamp, Transaction, TransactionAction, @@ -37,13 +39,13 @@ import { TransactionState, TransactionType, WalletAccountMergeFlags, - WalletKycUuid, assertUnreachable, checkDbInvariant, + codecForAccountKycStatus, codecForAny, codecForExchangeGetContractResponse, + codecForLegitimizationNeededResponse, codecForPeerContractTerms, - codecForWalletKycUuid, decodeCrock, eddsaGetPublic, encodeCrock, @@ -52,7 +54,10 @@ import { parsePayPushUri, talerPaytoFromExchangeReserve, } from "@gnu-taler/taler-util"; -import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http"; +import { + readResponseJsonOrThrow, + readSuccessResponseJsonOrThrow, +} from "@gnu-taler/taler-util/http"; import { PendingTaskType, TaskIdStr, @@ -67,7 +72,6 @@ import { requireExchangeTosAcceptedOrThrow, } from "./common.js"; import { - KycPendingInfo, OperationRetryRecord, PeerPushCreditStatus, PeerPushPaymentIncomingRecord, @@ -84,10 +88,15 @@ import { BalanceThresholdCheckResult, checkIncomingAmountLegalUnderKycBalanceThreshold, fetchFreshExchange, + getExchangeScopeInfo, getScopeForAllExchanges, handleStartExchangeWalletKyc, } from "./exchanges.js"; import { + checkPeerCreditHardLimitExceeded, + getPeerCreditLimitInfo, +} from "./kyc.js"; +import { codecForExchangePurseStatus, getMergeReserveInfo, } from "./pay-peer-common.js"; @@ -263,6 +272,11 @@ export class PeerPushCreditTransactionContext implements TransactionContext { const peerContractTerms = ct.contractTermsRaw; + let kycUrl: string | undefined = undefined; + if (wg?.kycAccessToken && wg.exchangeBaseUrl) { + kycUrl = new URL(`kyc-spa/${wg.kycAccessToken}`, wg.exchangeBaseUrl).href; + } + if (wg) { if (wg.wgInfo.withdrawalType !== WithdrawalRecordType.PeerPushCredit) { throw Error("invalid withdrawal group type for push payment credit"); @@ -291,8 +305,10 @@ export class PeerPushCreditTransactionContext implements TransactionContext { tag: TransactionType.PeerPushCredit, peerPushCreditId: pushInc.peerPushCreditId, }), - kycUrl: wg.kycUrl, - kycPaytoHash: wg.kycPending?.paytoHash, + abortReason: pushInc.abortReason, + failReason: pushInc.failReason, + kycUrl, + kycPaytoHash: wg.kycPaytoHash, ...(wgRetryRecord?.lastError ? { error: wgRetryRecord.lastError } : {}), }; } @@ -313,13 +329,15 @@ export class PeerPushCreditTransactionContext implements TransactionContext { expiration: peerContractTerms.purse_expiration, summary: peerContractTerms.summary, }, - kycUrl: pushInc.kycUrl, - kycPaytoHash: pushInc.kycInfo?.paytoHash, + kycUrl, + kycPaytoHash: pushInc.kycPaytoHash, timestamp: timestampPreciseFromDb(pushInc.timestamp), transactionId: constructTransactionIdentifier({ tag: TransactionType.PeerPushCredit, peerPushCreditId: pushInc.peerPushCreditId, }), + abortReason: pushInc.abortReason, + failReason: pushInc.failReason, ...(pushRetryRecord?.lastError ? { error: pushRetryRecord.lastError } : {}), @@ -521,7 +539,7 @@ export class PeerPushCreditTransactionContext implements TransactionContext { wex.taskScheduler.startShepherdTask(retryTag); } - async failTransaction(): Promise<void> { + async failTransaction(reason?: TalerErrorDetail): Promise<void> { const { wex, peerPushCreditId, taskId: retryTag, transactionId } = this; const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["peerPushCredit", "transactionsMeta"] }, @@ -531,7 +549,7 @@ export class PeerPushCreditTransactionContext implements TransactionContext { logger.warn(`peer push credit ${peerPushCreditId} not found`); return; } - let newStatus: PeerPushCreditStatus | undefined = undefined; + let newStatus: PeerPushCreditStatus; switch (pushCreditRec.status) { case PeerPushCreditStatus.Done: case PeerPushCreditStatus.Aborted: @@ -554,20 +572,16 @@ export class PeerPushCreditTransactionContext implements TransactionContext { default: assertUnreachable(pushCreditRec.status); } - if (newStatus != null) { - const oldTxState = - computePeerPushCreditTransactionState(pushCreditRec); - pushCreditRec.status = newStatus; - const newTxState = - computePeerPushCreditTransactionState(pushCreditRec); - await tx.peerPushCredit.put(pushCreditRec); - await this.updateTransactionMeta(tx); - return { - oldTxState, - newTxState, - }; - } - return undefined; + const oldTxState = computePeerPushCreditTransactionState(pushCreditRec); + pushCreditRec.status = newStatus; + pushCreditRec.failReason = reason; + const newTxState = computePeerPushCreditTransactionState(pushCreditRec); + await tx.peerPushCredit.put(pushCreditRec); + await this.updateTransactionMeta(tx); + return { + oldTxState, + newTxState, + }; }, ); wex.taskScheduler.stopShepherdTask(retryTag); @@ -586,6 +600,10 @@ export async function preparePeerPushCredit( throw Error("got invalid taler://pay-push URI"); } + // add exchange entry if it doesn't exist already! + const exchangeBaseUrl = uri.exchangeBaseUrl; + const exchange = await fetchFreshExchange(wex, exchangeBaseUrl); + const existing = await wex.db.runReadOnlyTx( { storeNames: ["contractTerms", "peerPushCredit"] }, async (tx) => { @@ -613,22 +631,30 @@ export async function preparePeerPushCredit( ); if (existing) { + const currency = Amounts.currencyOf(existing.existingContractTerms.amount); + const exchangeBaseUrl = existing.existingPushInc.exchangeBaseUrl; + const scopeInfo = await wex.db.runAllStoresReadOnlyTx( + {}, + async (tx) => await getExchangeScopeInfo(tx, exchangeBaseUrl, currency), + ); return { amount: existing.existingContractTerms.amount, amountEffective: existing.existingPushInc.estimatedAmountEffective, amountRaw: existing.existingContractTerms.amount, contractTerms: existing.existingContractTerms, - peerPushCreditId: existing.existingPushInc.peerPushCreditId, transactionId: constructTransactionIdentifier({ tag: TransactionType.PeerPushCredit, peerPushCreditId: existing.existingPushInc.peerPushCreditId, }), - exchangeBaseUrl: existing.existingPushInc.exchangeBaseUrl, + scopeInfo, + exchangeBaseUrl, + ...getPeerCreditLimitInfo( + exchange, + existing.existingContractTerms.amount, + ), }; } - const exchangeBaseUrl = uri.exchangeBaseUrl; - const contractPriv = uri.contractPriv; const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv))); @@ -649,12 +675,12 @@ export async function preparePeerPushCredit( pursePub: pursePub, }); + const contractTerms = codecForPeerContractTerms().decode(dec.contractTerms); + const getPurseUrl = new URL(`purses/${pursePub}/deposit`, exchangeBaseUrl); const purseHttpResp = await wex.http.fetch(getPurseUrl.href); - const contractTerms = codecForPeerContractTerms().decode(dec.contractTerms); - const purseStatus = await readSuccessResponseJsonOrThrow( purseHttpResp, codecForExchangePurseStatus(), @@ -685,7 +711,7 @@ export async function preparePeerPushCredit( ); } - const ctx = new PeerPushCreditTransactionContext(wex, withdrawalGroupId); + const ctx = new PeerPushCreditTransactionContext(wex, peerPushCreditId); const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["contractTerms", "peerPushCredit", "transactionsMeta"] }, @@ -723,26 +749,24 @@ 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); - wex.ws.notify({ - type: NotificationType.BalanceChange, - hintTransactionId: transactionId, - }); + const scopeInfo = await wex.db.runAllStoresReadOnlyTx( + {}, + async (tx) => await getExchangeScopeInfo(tx, exchangeBaseUrl, currency), + ); return { amount: purseStatus.balance, amountEffective: wi.withdrawalAmountEffective, amountRaw: purseStatus.balance, contractTerms: dec.contractTerms, - peerPushCreditId, - transactionId, + transactionId: ctx.transactionId, exchangeBaseUrl, + scopeInfo, + ...getPeerCreditLimitInfo(exchange, purseStatus.balance), }; } @@ -750,7 +774,7 @@ async function longpollKycStatus( wex: WalletExecutionContext, peerPushCreditId: string, exchangeUrl: string, - kycInfo: KycPendingInfo, + kycPaytoHash: string, ): Promise<TaskRunResult> { const ctx = new PeerPushCreditTransactionContext(wex, peerPushCreditId); @@ -764,7 +788,7 @@ async function longpollKycStatus( accountPub: mergeReserveInfo.reservePub, }); - const url = new URL(`kyc-check/${kycInfo.requirementRow}`, exchangeUrl); + const url = new URL(`kyc-check/${kycPaytoHash}`, exchangeUrl); logger.info(`kyc url ${url.href}`); const kycStatusRes = await wex.ws.runLongpollQueueing( wex, @@ -816,7 +840,7 @@ async function longpollKycStatus( async function processPeerPushCreditKycRequired( wex: WalletExecutionContext, peerInc: PeerPushPaymentIncomingRecord, - kycPending: WalletKycUuid, + kycPending: LegitimizationNeededResponse, ): Promise<TaskRunResult> { const transactionId = constructTransactionIdentifier({ tag: TransactionType.PeerPushCredit, @@ -839,7 +863,7 @@ async function processPeerPushCreditKycRequired( }); const url = new URL( - `kyc-check/${kycPending.requirement_row}`, + `kyc-check/${kycPending.h_payto}`, peerInc.exchangeBaseUrl, ); @@ -861,8 +885,11 @@ async function processPeerPushCreditKycRequired( logger.warn("kyc requested, but already fulfilled"); return TaskRunResult.finished(); } else if (kycStatusRes.status === HttpStatusCode.Accepted) { - const kycStatus = await kycStatusRes.json(); - logger.info(`kyc status: ${j2s(kycStatus)}`); + const statusResp = await readResponseJsonOrThrow( + kycStatusRes, + codecForAccountKycStatus(), + ); + logger.info(`kyc status: ${j2s(statusResp)}`); const { transitionInfo, result } = await wex.db.runReadWriteTx( { storeNames: ["peerPushCredit", "transactionsMeta"] }, async (tx) => { @@ -874,11 +901,8 @@ async function processPeerPushCreditKycRequired( }; } const oldTxState = computePeerPushCreditTransactionState(peerInc); - peerInc.kycInfo = { - paytoHash: kycPending.h_payto, - requirementRow: kycPending.requirement_row, - }; - peerInc.kycUrl = kycStatus.kyc_url; + peerInc.kycPaytoHash = kycPending.h_payto; + peerInc.kycAccessToken = statusResp.access_token; peerInc.status = PeerPushCreditStatus.PendingMergeKycRequired; const newTxState = computePeerPushCreditTransactionState(peerInc); await tx.peerPushCredit.put(peerInc); @@ -978,10 +1002,14 @@ async function handlePendingMerge( }); if (mergeHttpResp.status === HttpStatusCode.UnavailableForLegalReasons) { - const respJson = await mergeHttpResp.json(); - const kycPending = codecForWalletKycUuid().decode(respJson); - logger.info(`kyc uuid response: ${j2s(kycPending)}`); - return processPeerPushCreditKycRequired(wex, peerInc, kycPending); + const kycLegiNeededResp = await readResponseJsonOrThrow( + mergeHttpResp, + codecForLegitimizationNeededResponse(), + ); + logger.info( + `kyc legitimization needed response: ${j2s(kycLegiNeededResp)}`, + ); + return processPeerPushCreditKycRequired(wex, peerInc, kycLegiNeededResp); } logger.trace(`merge request: ${j2s(mergeReq)}`); @@ -1163,14 +1191,14 @@ export async function processPeerPushCredit( switch (peerInc.status) { case PeerPushCreditStatus.PendingMergeKycRequired: { - if (!peerInc.kycInfo) { - throw Error("invalid state, kycInfo required"); + if (!peerInc.kycPaytoHash) { + throw Error("invalid state, kycPaytoHash required"); } return await longpollKycStatus( wex, peerPushCreditId, peerInc.exchangeBaseUrl, - peerInc.kycInfo, + peerInc.kycPaytoHash, ); } case PeerPushCreditStatus.PendingMerge: { @@ -1255,13 +1283,7 @@ async function processPeerPushCreditBalanceKyc( return TransitionResult.stay(); } rec.status = PeerPushCreditStatus.PendingBalanceKycRequired; - delete rec.kycInfo; rec.kycAccessToken = ret.walletKycAccessToken; - // FIXME: #9109 this should not be constructed here, it should be an opaque URL from exchange response - rec.kycUrl = new URL( - `kyc-spa/${ret.walletKycAccessToken}`, - exchangeBaseUrl, - ).href; return TransitionResult.transition(rec); }); return TaskRunResult.progress(); @@ -1288,31 +1310,55 @@ export async function confirmPeerPushCredit( logger.trace(`confirming peer-push-credit ${ctx.peerPushCreditId}`); - const peerInc = await wex.db.runReadWriteTx( + const res = await wex.db.runReadWriteTx( { storeNames: ["contractTerms", "peerPushCredit", "transactionsMeta"] }, async (tx) => { const rec = await tx.peerPushCredit.get(ctx.peerPushCreditId); if (!rec) { return; } - if (rec.status === PeerPushCreditStatus.DialogProposed) { - rec.status = PeerPushCreditStatus.PendingMerge; + const ct = await tx.contractTerms.get(rec.contractTermsHash); + if (!ct) { + return undefined; } - await tx.peerPushCredit.put(rec); - await ctx.updateTransactionMeta(tx); - return rec; + return { + peerInc: rec, + contractTerms: ct.contractTermsRaw as PeerContractTerms, + }; }, ); - if (!peerInc) { + if (!res) { throw Error( `can't accept unknown incoming p2p push payment (${req.transactionId})`, ); } + const peerInc = res.peerInc; + const exchange = await fetchFreshExchange(wex, peerInc.exchangeBaseUrl); requireExchangeTosAcceptedOrThrow(exchange); + if (checkPeerCreditHardLimitExceeded(exchange, res.contractTerms.amount)) { + throw Error("peer credit would exceed hard KYC limit"); + } + + await wex.db.runReadWriteTx( + { storeNames: ["contractTerms", "peerPushCredit", "transactionsMeta"] }, + async (tx) => { + const rec = await tx.peerPushCredit.get(ctx.peerPushCreditId); + if (!rec) { + return; + } + if (rec.status === PeerPushCreditStatus.DialogProposed) { + rec.status = PeerPushCreditStatus.PendingMerge; + } + await tx.peerPushCredit.put(rec); + await ctx.updateTransactionMeta(tx); + return rec; + }, + ); + wex.taskScheduler.startShepherdTask(ctx.taskId); return { @@ -1444,3 +1490,6 @@ export function computePeerPushCreditTransactionActions( assertUnreachable(pushCreditRecord.status); } } +function checkPeerHardLimitExceeded(exchanges: any, amount: any) { + throw new Error("Function not implemented."); +} diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts index 4485976b9..31d320259 100644 --- a/packages/taler-wallet-core/src/pay-peer-push-debit.ts +++ b/packages/taler-wallet-core/src/pay-peer-push-debit.ts @@ -25,11 +25,13 @@ import { InitiatePeerPushDebitRequest, InitiatePeerPushDebitResponse, Logger, - NotificationType, RefreshReason, + ScopeInfo, + ScopeType, SelectedProspectiveCoin, TalerError, TalerErrorCode, + TalerErrorDetail, TalerPreciseTimestamp, TalerProtocolTimestamp, TalerProtocolViolationError, @@ -80,6 +82,7 @@ import { timestampProtocolFromDb, timestampProtocolToDb, } from "./db.js"; +import { getScopeForAllExchanges } from "./exchanges.js"; import { codecForExchangePurseStatus, getTotalPeerPaymentCost, @@ -93,7 +96,6 @@ import { notifyTransition, } from "./transactions.js"; import { WalletExecutionContext } from "./wallet.js"; -import { getScopeForAllExchanges } from "./exchanges.js"; const logger = new Logger("pay-peer-push-debit.ts"); @@ -185,6 +187,8 @@ export class PeerPushDebitTransactionContext implements TransactionContext { tag: TransactionType.PeerPushDebit, pursePub: pushDebitRec.pursePub, }), + failReason: pushDebitRec.failReason, + abortReason: pushDebitRec.abortReason, ...(retryRec?.lastError ? { error: retryRec.lastError } : {}), }; } @@ -263,7 +267,7 @@ export class PeerPushDebitTransactionContext implements TransactionContext { notifyTransition(wex, transactionId, transitionInfo); } - async abortTransaction(): Promise<void> { + async abortTransaction(reason?: TalerErrorDetail): Promise<void> { const { wex, pursePub, transactionId, taskId: retryTag } = this; const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["peerPushDebit", "transactionsMeta"] }, @@ -277,11 +281,13 @@ export class PeerPushDebitTransactionContext implements TransactionContext { switch (pushDebitRec.status) { case PeerPushDebitStatus.PendingReady: case PeerPushDebitStatus.SuspendedReady: + pushDebitRec.abortReason = reason; newStatus = PeerPushDebitStatus.AbortingDeletePurse; break; case PeerPushDebitStatus.SuspendedCreatePurse: case PeerPushDebitStatus.PendingCreatePurse: // Network request might already be in-flight! + pushDebitRec.abortReason = reason; newStatus = PeerPushDebitStatus.AbortingDeletePurse; break; case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted: @@ -377,7 +383,7 @@ export class PeerPushDebitTransactionContext implements TransactionContext { notifyTransition(wex, transactionId, transitionInfo); } - async failTransaction(): Promise<void> { + async failTransaction(reason?: TalerErrorDetail): Promise<void> { const { wex, pursePub, transactionId, taskId: retryTag } = this; const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["peerPushDebit", "transactionsMeta"] }, @@ -387,7 +393,7 @@ export class PeerPushDebitTransactionContext implements TransactionContext { logger.warn(`peer push debit ${pursePub} not found`); return; } - let newStatus: PeerPushDebitStatus | undefined = undefined; + let newStatus: PeerPushDebitStatus; switch (pushDebitRec.status) { case PeerPushDebitStatus.AbortingRefreshDeleted: case PeerPushDebitStatus.SuspendedAbortingRefreshDeleted: @@ -409,22 +415,20 @@ export class PeerPushDebitTransactionContext implements TransactionContext { case PeerPushDebitStatus.Failed: case PeerPushDebitStatus.Expired: // Do nothing - break; + return undefined; default: assertUnreachable(pushDebitRec.status); } - if (newStatus != null) { - const oldTxState = computePeerPushDebitTransactionState(pushDebitRec); - pushDebitRec.status = newStatus; - const newTxState = computePeerPushDebitTransactionState(pushDebitRec); - await tx.peerPushDebit.put(pushDebitRec); - await this.updateTransactionMeta(tx); - return { - oldTxState, - newTxState, - }; - } - return undefined; + const oldTxState = computePeerPushDebitTransactionState(pushDebitRec); + pushDebitRec.status = newStatus; + pushDebitRec.failReason = reason; + const newTxState = computePeerPushDebitTransactionState(pushDebitRec); + await tx.peerPushDebit.put(pushDebitRec); + await this.updateTransactionMeta(tx); + return { + oldTxState, + newTxState, + }; }, ); wex.taskScheduler.stopShepherdTask(retryTag); @@ -450,11 +454,24 @@ 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 | undefined = undefined; + if (req.restrictScope) { + restrictScope = req.restrictScope; + } else if (req.exchangeBaseUrl) { + restrictScope = { + type: ScopeType.Exchange, + currency, + url: req.exchangeBaseUrl, + }; + } const coinSelRes = await selectPeerCoins(wex, { instructedAmount, + restrictScope, + feesCoveredByCounterparty: false, }); let coins: SelectedProspectiveCoin[] | undefined = undefined; switch (coinSelRes.type) { @@ -527,8 +544,10 @@ async function handlePurseCreationConflict( } const coinSelRes = await selectPeerCoins(wex, { - instructedAmount, + instructedAmount: Amounts.parseOrThrow(peerPushInitiation.amount), + restrictScope: peerPushInitiation.restrictScope, repair, + feesCoveredByCounterparty: false, }); switch (coinSelRes.type) { @@ -599,6 +618,8 @@ async function processPeerPushDebitCreateReserve( if (!peerPushInitiation.coinSel) { const coinSelRes = await selectPeerCoins(wex, { instructedAmount: Amounts.parseOrThrow(peerPushInitiation.amount), + restrictScope: peerPushInitiation.restrictScope, + feesCoveredByCounterparty: false, }); switch (coinSelRes.type) { @@ -667,11 +688,13 @@ async function processPeerPushDebitCreateReserve( return TaskRunResult.backoff(); } + const purseAmount = peerPushInitiation.amount; + const purseSigResp = await wex.cryptoApi.signPurseCreation({ hContractTerms, mergePub: peerPushInitiation.mergePub, minAge: 0, - purseAmount: peerPushInitiation.amount, + purseAmount, purseExpiration: timestampProtocolFromDb(purseExpiration), pursePriv: peerPushInitiation.pursePriv, }); @@ -718,7 +741,8 @@ async function processPeerPushDebitCreateReserve( ); const reqBody = { - amount: peerPushInitiation.amount, + // Older wallets do not have amountPurse + amount: purseAmount, merge_pub: peerPushInitiation.mergePub, purse_sig: purseSigResp.sig, h_contract_terms: hContractTerms, @@ -743,6 +767,8 @@ async function processPeerPushDebitCreateReserve( // Possibly on to the next batch. continue; case HttpStatusCode.Forbidden: { + const errResp = await readTalerErrorResponse(httpResp); + logger.error(`${j2s(errResp)}`); // FIXME: Store this error! await ctx.failTransaction(); return TaskRunResult.finished(); @@ -778,7 +804,7 @@ async function processPeerPushDebitCreateReserve( switch (httpResp.status) { case HttpStatusCode.Ok: // Possibly on to the next batch. - continue; + break; case HttpStatusCode.Forbidden: { // FIXME: Store this error! await ctx.failTransaction(); @@ -801,6 +827,22 @@ async function processPeerPushDebitCreateReserve( // All batches done! + const getPurseUrl = new URL( + `purses/${pursePub}/deposit`, + peerPushInitiation.exchangeBaseUrl, + ); + + const purseHttpResp = await wex.http.fetch(getPurseUrl.href); + + const purseStatus = await readSuccessResponseJsonOrThrow( + purseHttpResp, + codecForExchangePurseStatus(), + ); + + if (logger.shouldLogTrace()) { + logger.trace(`purse status: ${j2s(purseStatus)}`); + } + await transitionPeerPushDebitTransaction(wex, pursePub, { stFrom: PeerPushDebitStatus.PendingCreatePurse, stTo: PeerPushDebitStatus.PendingReady, @@ -1192,13 +1234,11 @@ export async function initiatePeerPushDebit( req.partialContractTerms.amount, ); const purseExpiration = req.partialContractTerms.purse_expiration; - const contractTerms = req.partialContractTerms; + const contractTerms = { ...req.partialContractTerms }; const pursePair = await wex.cryptoApi.createEddsaKeypair({}); const mergePair = await wex.cryptoApi.createEddsaKeypair({}); - const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms); - const contractKeyPair = await wex.cryptoApi.createEddsaKeypair({}); const pursePub = pursePair.pub; @@ -1209,6 +1249,8 @@ export async function initiatePeerPushDebit( const contractEncNonce = encodeCrock(getRandomBytes(24)); + const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms); + const res = await wex.db.runReadWriteTx( { storeNames: [ @@ -1223,11 +1265,15 @@ export async function initiatePeerPushDebit( "refreshGroups", "refreshSessions", "transactionsMeta", + "globalCurrencyExchanges", + "globalCurrencyAuditors", ], }, async (tx) => { const coinSelRes = await selectPeerCoinsInTx(wex, tx, { instructedAmount, + restrictScope: req.restrictScope, + feesCoveredByCounterparty: false, }); let coins: SelectedProspectiveCoin[] | undefined = undefined; @@ -1250,11 +1296,26 @@ export async function initiatePeerPushDebit( assertUnreachable(coinSelRes); } + logger.trace(j2s(coinSelRes)); + const sel = coinSelRes.result; + logger.trace( + `peer debit instructed amount: ${Amounts.stringify(instructedAmount)}`, + ); + logger.trace( + `peer debit contract terms amount: ${Amounts.stringify( + contractTerms.amount, + )}`, + ); + logger.trace( + `peer debit deposit fees: ${Amounts.stringify(sel.totalDepositFees)}`, + ); + const totalAmount = await getTotalPeerPaymentCostInTx(wex, tx, coins); const ppi: PeerPushDebitRecord = { amount: Amounts.stringify(instructedAmount), + restrictScope: req.restrictScope, contractPriv: contractKeyPair.priv, contractPub: contractKeyPair.pub, contractTermsHash: hContractTerms, @@ -1307,10 +1368,6 @@ export async function initiatePeerPushDebit( }, ); notifyTransition(wex, transactionId, res.transitionInfo); - wex.ws.notify({ - type: NotificationType.BalanceChange, - hintTransactionId: transactionId, - }); wex.taskScheduler.startShepherdTask(ctx.taskId); diff --git a/packages/taler-wallet-core/src/recoup.ts b/packages/taler-wallet-core/src/recoup.ts index d39be32bd..e74e49118 100644 --- a/packages/taler-wallet-core/src/recoup.ts +++ b/packages/taler-wallet-core/src/recoup.ts @@ -67,7 +67,7 @@ import { constructTransactionIdentifier } from "./transactions.js"; import { WalletExecutionContext, getDenomInfo } from "./wallet.js"; import { internalCreateWithdrawalGroup } from "./withdraw.js"; -export const logger = new Logger("operations/recoup.ts"); +const logger = new Logger("operations/recoup.ts"); /** * Store a recoup group record in the database after marking diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts index b8bd3c0b7..033a85adf 100644 --- a/packages/taler-wallet-core/src/refresh.ts +++ b/packages/taler-wallet-core/src/refresh.ts @@ -110,6 +110,7 @@ import { WalletDbStoresArr, } from "./db.js"; import { selectWithdrawalDenominations } from "./denomSelection.js"; +import { getScopeForAllExchanges } from "./exchanges.js"; import { constructTransactionIdentifier, isUnsuccessfulTransaction, @@ -122,7 +123,6 @@ import { WalletExecutionContext, } from "./wallet.js"; import { getCandidateWithdrawalDenomsTx } from "./withdraw.js"; -import { getScopeForAllExchanges } from "./exchanges.js"; const logger = new Logger("refresh.ts"); @@ -187,7 +187,12 @@ export class RefreshTransactionContext implements TransactionContext { return { type: TransactionType.Refresh, txState, - scopes: await getScopeForAllExchanges(tx, !refreshGroupRecord.infoPerExchange? []: Object.keys(refreshGroupRecord.infoPerExchange)), + scopes: await getScopeForAllExchanges( + tx, + !refreshGroupRecord.infoPerExchange + ? [] + : Object.keys(refreshGroupRecord.infoPerExchange), + ), txActions: computeRefreshTransactionActions(refreshGroupRecord), refreshReason: refreshGroupRecord.reason, amountEffective: isUnsuccessfulTransaction(txState) @@ -204,6 +209,7 @@ export class RefreshTransactionContext implements TransactionContext { tag: TransactionType.Refresh, refreshGroupId: refreshGroupRecord.refreshGroupId, }), + failReason: refreshGroupRecord.failReason, ...(ort?.lastError ? { error: ort.lastError } : {}), }; } @@ -341,7 +347,7 @@ export class RefreshTransactionContext implements TransactionContext { }); } - async failTransaction(): Promise<void> { + async failTransaction(reason?: TalerErrorDetail): Promise<void> { await this.transition({}, async (rec, tx) => { if (!rec) { return TransitionResult.stay(); @@ -353,6 +359,7 @@ export class RefreshTransactionContext implements TransactionContext { case RefreshOperationStatus.Pending: case RefreshOperationStatus.Suspended: { rec.operationStatus = RefreshOperationStatus.Failed; + rec.failReason = reason; return TransitionResult.transition(rec); } default: @@ -406,6 +413,11 @@ export function getTotalRefreshCostInternal( refreshedDenom: DenominationInfo, amountLeft: AmountJson, ): AmountJson { + logger.trace( + `computing total refresh cost, denom value ${ + refreshedDenom.value + }, amount left ${Amounts.stringify(amountLeft)}`, + ); const withdrawAmount = Amounts.sub( amountLeft, refreshedDenom.feeRefresh, @@ -1748,6 +1760,12 @@ export async function createRefreshGroup( const estimatedOutputPerCoin = outInfo.outputPerCoin; + if (logger.shouldLogTrace()) { + logger.trace( + `creating refresh group, inputs ${j2s(oldCoinPubs.map((x) => x.amount))}`, + ); + } + await applyRefreshToOldCoins(wex, tx, oldCoinPubs, refreshGroupId); const refreshGroup: RefreshGroupRecord = { diff --git a/packages/taler-wallet-core/src/shepherd.ts b/packages/taler-wallet-core/src/shepherd.ts index c52c55f50..03dd683b2 100644 --- a/packages/taler-wallet-core/src/shepherd.ts +++ b/packages/taler-wallet-core/src/shepherd.ts @@ -396,6 +396,14 @@ export class TaskSchedulerImpl implements TaskScheduler { try { res = await callOperationHandlerForTaskId(wex, taskId); } catch (e) { + if (info.cts.token.isCancelled) { + logger.trace(`task ${taskId} cancelled, ignoring task error`); + return; + } + if (this.ws.stopped) { + logger.trace("wallet stopped, ignoring task error"); + return; + } const errorDetail = getErrorDetailFromException(e); logger.trace( `Shepherd error ${taskId} saving response ${j2s(errorDetail)}`, diff --git a/packages/taler-wallet-core/src/testing.ts b/packages/taler-wallet-core/src/testing.ts index 225773f8c..ce1d14013 100644 --- a/packages/taler-wallet-core/src/testing.ts +++ b/packages/taler-wallet-core/src/testing.ts @@ -78,7 +78,11 @@ import { } from "./pay-peer-push-credit.js"; import { initiatePeerPushDebit } from "./pay-peer-push-debit.js"; import { getRefreshesForTransaction } from "./refresh.js"; -import { getTransactionById, getTransactions } from "./transactions.js"; +import { + getTransactionById, + getTransactions, + parseTransactionIdentifier, +} from "./transactions.js"; import type { WalletExecutionContext } from "./wallet.js"; import { acceptBankIntegratedWithdrawal } from "./withdraw.js"; @@ -583,7 +587,7 @@ async function waitUntilTransactionPendingReady( export async function waitTransactionState( wex: WalletExecutionContext, transactionId: string, - txState: TransactionState, + txState: TransactionState | TransactionState[], ): Promise<void> { logger.info( `starting waiting for ${transactionId} to be in ${JSON.stringify( @@ -595,9 +599,22 @@ export async function waitTransactionState( const tx = await getTransactionById(wex, { transactionId, }); - return ( - tx.txState.major === txState.major && tx.txState.minor === txState.minor - ); + if (Array.isArray(txState)) { + for (const myState of txState) { + if ( + tx.txState.major === myState.major && + tx.txState.minor === myState.minor + ) { + return true; + } + } + return false; + } else { + return ( + tx.txState.major === txState.major && + tx.txState.minor === txState.minor + ); + } }, filterNotification(notif) { return notif.type === NotificationType.TransactionStateTransition; @@ -871,7 +888,9 @@ export async function testPay( const purchase = await wex.db.runReadOnlyTx( { storeNames: ["purchases"] }, async (tx) => { - return tx.purchases.get(result.proposalId); + const parsedTx = parseTransactionIdentifier(r.transactionId); + checkLogicInvariant(parsedTx?.tag === TransactionType.Payment); + return tx.purchases.get(parsedTx.proposalId); }, ); checkLogicInvariant(!!purchase); diff --git a/packages/taler-wallet-core/src/transactions.ts b/packages/taler-wallet-core/src/transactions.ts index e314c0e46..b77393f6e 100644 --- a/packages/taler-wallet-core/src/transactions.ts +++ b/packages/taler-wallet-core/src/transactions.ts @@ -17,15 +17,22 @@ /** * Imports. */ -import { GlobalIDB, IDBKeyRange } from "@gnu-taler/idb-bridge"; +import { + BridgeIDBKeyRange, + GlobalIDB, + IDBKeyRange, +} from "@gnu-taler/idb-bridge"; import { AbsoluteTime, Amounts, assertUnreachable, + GetTransactionsV2Request, j2s, Logger, + makeTalerErrorDetail, NotificationType, ScopeType, + TalerErrorCode, Transaction, TransactionByIdRequest, TransactionIdStr, @@ -42,8 +49,14 @@ import { TransactionContext, } from "./common.js"; import { + DbPreciseTimestamp, + OPERATION_STATUS_DONE_FIRST, + OPERATION_STATUS_DONE_LAST, OPERATION_STATUS_NONFINAL_FIRST, OPERATION_STATUS_NONFINAL_LAST, + timestampPreciseToDb, + TransactionMetaRecord, + WalletDbAllStoresReadOnlyTransaction, WalletDbAllStoresReadWriteTransaction, } from "./db.js"; import { DepositTransactionContext } from "./deposits.js"; @@ -63,7 +76,10 @@ import { WithdrawTransactionContext } from "./withdraw.js"; const logger = new Logger("taler-wallet-core:transactions.ts"); function shouldSkipCurrency( - transactionsRequest: TransactionsRequest | undefined, + transactionsRequest: + | TransactionsRequest + | GetTransactionsV2Request + | undefined, currency: string, exchangesInTransaction: string[], ): boolean { @@ -91,7 +107,6 @@ function shouldSkipCurrency( assertUnreachable(transactionsRequest.scopeInfo); } } - // FIXME: remove next release if (transactionsRequest?.currency) { return ( transactionsRequest.currency.toLowerCase() !== currency.toLowerCase() @@ -163,6 +178,249 @@ export function isUnsuccessfulTransaction(state: TransactionState): boolean { ); } +function checkFilterIncludes( + req: GetTransactionsV2Request | undefined, + mtx: TransactionMetaRecord, +): boolean { + if (shouldSkipCurrency(req, mtx.currency, mtx.exchanges)) { + return false; + } + + const parsedTx = parseTransactionIdentifier(mtx.transactionId); + if (parsedTx?.tag === TransactionType.Refresh && !req?.includeRefreshes) { + return false; + } + + let included: boolean; + + const filter = req?.filterByState; + switch (filter) { + case "done": + included = + mtx.status >= OPERATION_STATUS_DONE_FIRST && + mtx.status <= OPERATION_STATUS_DONE_LAST; + break; + case "final": + included = !( + mtx.status >= OPERATION_STATUS_NONFINAL_FIRST && + mtx.status <= OPERATION_STATUS_NONFINAL_LAST + ); + break; + case "nonfinal": + included = + mtx.status >= OPERATION_STATUS_NONFINAL_FIRST && + mtx.status <= OPERATION_STATUS_NONFINAL_LAST; + break; + case undefined: + included = true; + break; + default: + assertUnreachable(filter); + } + return included; +} + +async function addFiltered( + wex: WalletExecutionContext, + tx: WalletDbAllStoresReadOnlyTransaction, + req: GetTransactionsV2Request | undefined, + target: Transaction[], + source: TransactionMetaRecord[], +): Promise<number> { + let numAdded: number = 0; + for (const mtx of source) { + if (req?.limit != null && target.length >= Math.abs(req.limit)) { + break; + } + if (checkFilterIncludes(req, mtx)) { + const ctx = await getContextForTransaction(wex, mtx.transactionId); + const txDetails = await ctx.lookupFullTransaction(tx); + // FIXME: This means that in some cases we can return fewer transactions + // than requested. + if (!txDetails) { + continue; + } + numAdded += 1; + target.push(txDetails); + } + } + return numAdded; +} + +/** + * Sort transactions in-place according to the request. + */ +function sortTransactions( + req: GetTransactionsV2Request | undefined, + transactions: Transaction[], +): void { + let sortSign: number; + if (req?.limit != null && req.limit < 0) { + sortSign = -1; + } else { + sortSign = 1; + } + const txCmp = (h1: Transaction, h2: Transaction) => { + // Order transactions by timestamp. Newest transactions come first. + const tsCmp = AbsoluteTime.cmp( + AbsoluteTime.fromPreciseTimestamp(h1.timestamp), + AbsoluteTime.fromPreciseTimestamp(h2.timestamp), + ); + // If the timestamp is exactly the same, order by transaction type. + if (tsCmp === 0) { + return Math.sign(txOrder[h1.type] - txOrder[h2.type]); + } + return sortSign * tsCmp; + }; + transactions.sort(txCmp); +} + +async function findOffsetTransaction( + tx: WalletDbAllStoresReadOnlyTransaction, + req?: GetTransactionsV2Request, +): Promise<TransactionMetaRecord | undefined> { + let forwards = req?.limit == null || req.limit >= 0; + let closestTimestamp: DbPreciseTimestamp | undefined = undefined; + if (req?.offsetTransactionId) { + const res = await tx.transactionsMeta.get(req.offsetTransactionId); + if (res) { + return res; + } + if (req.offsetTimestamp) { + closestTimestamp = timestampPreciseToDb(req.offsetTimestamp); + } else { + throw Error( + "offset transaction not found and no offset timestamp specified", + ); + } + } else if (req?.offsetTimestamp) { + const dbStamp = timestampPreciseToDb(req.offsetTimestamp); + const res = await tx.transactionsMeta.indexes.byTimestamp.get(dbStamp); + if (res) { + return res; + } + closestTimestamp = timestampPreciseToDb(req.offsetTimestamp); + } else { + return undefined; + } + + // We didn't find a precise offset transaction. + // This must mean that it was deleted. + // Depending on the direction, find the prev/next + // transaction and use it as an offset. + + if (forwards) { + // We don't want to skip transactions in pagination, + // so get the transaction before the timestamp + + // Slow query, but should not happen often! + const recs = await tx.transactionsMeta.indexes.byTimestamp.getAll( + BridgeIDBKeyRange.upperBound(closestTimestamp, false), + ); + if (recs.length > 0) { + return recs[recs.length - 1]; + } + return undefined; + } else { + // Likewise, get the transaction after the timestamp + const recs = await tx.transactionsMeta.indexes.byTimestamp.getAll( + BridgeIDBKeyRange.lowerBound(closestTimestamp, false), + 1, + ); + return recs[0]; + } +} + +export async function getTransactionsV2( + wex: WalletExecutionContext, + transactionsRequest?: GetTransactionsV2Request, +): Promise<TransactionsResponse> { + // The implementation of this function is optimized for + // the common fast path of requesting transactions + // in an ascending order. + // Other requests are more difficult to implement + // in a performant way due to IndexedDB limitations. + + const resultTransactions: Transaction[] = []; + + await wex.db.runAllStoresReadOnlyTx({}, async (tx) => { + let forwards = + transactionsRequest?.limit == null || transactionsRequest.limit >= 0; + let limit = + transactionsRequest?.limit != null + ? Math.abs(transactionsRequest.limit) + : undefined; + let offsetMtx = await findOffsetTransaction(tx, transactionsRequest); + + if (limit == null && offsetMtx == null) { + // Fast path for returning *everything* that matches the filter. + // FIXME: We could use the DB for filtering here + const res = await tx.transactionsMeta.indexes.byStatus.getAll(); + await addFiltered(wex, tx, transactionsRequest, resultTransactions, res); + } else if (!forwards) { + // Descending, backwards request. + // Slow implementation. Doing it properly would require using cursors, + // which are also slow in IndexedDB. + const res = await tx.transactionsMeta.indexes.byTimestamp.getAll(); + res.reverse(); + let start: number; + if (offsetMtx != null) { + const needleTxId = offsetMtx.transactionId; + start = res.findIndex((x) => x.transactionId === needleTxId); + if (start < 0) { + throw Error("offset transaction not found"); + } + } else { + start = 0; + } + for (let i = start; i < res.length; i++) { + if (limit != null && resultTransactions.length >= limit) { + break; + } + await addFiltered(wex, tx, transactionsRequest, resultTransactions, [ + res[i], + ]); + } + } else { + // Forward request + let query: BridgeIDBKeyRange | undefined = undefined; + if (offsetMtx != null) { + query = GlobalIDB.KeyRange.lowerBound(offsetMtx.timestamp, true); + } + while (true) { + const res = await tx.transactionsMeta.indexes.byTimestamp.getAll( + query, + limit, + ); + if (res.length === 0) { + break; + } + await addFiltered( + wex, + tx, + transactionsRequest, + resultTransactions, + res, + ); + if (limit != null && resultTransactions.length >= limit) { + break; + } + // Continue after last result + query = BridgeIDBKeyRange.lowerBound( + res[res.length - 1].timestamp, + true, + ); + } + } + }); + + sortTransactions(transactionsRequest, resultTransactions); + + return { + transactions: resultTransactions, + }; +} + /** * Retrieve the full event history for this wallet. */ @@ -598,7 +856,12 @@ export async function failTransaction( transactionId: string, ): Promise<void> { const ctx = await getContextForTransaction(wex, transactionId); - await ctx.failTransaction(); + await ctx.failTransaction( + makeTalerErrorDetail( + TalerErrorCode.WALLET_TRANSACTION_ABANDONED_BY_USER, + {}, + ), + ); } /** @@ -631,7 +894,9 @@ export async function abortTransaction( transactionId: string, ): Promise<void> { const ctx = await getContextForTransaction(wex, transactionId); - await ctx.abortTransaction(); + await ctx.abortTransaction( + makeTalerErrorDetail(TalerErrorCode.WALLET_TRANSACTION_ABORTED_BY_USER, {}), + ); } export interface TransitionInfo { @@ -662,5 +927,16 @@ export function notifyTransition( transactionId, experimentalUserData, }); + + // As a heuristic, we emit balance-change notifications + // whenever the major state changes. + // This sometimes emits more notifications than we need, + // but makes it much more unlikely that we miss any. + if (transitionInfo.newTxState.major !== transitionInfo.oldTxState.major) { + wex.ws.notify({ + type: NotificationType.BalanceChange, + hintTransactionId: transactionId, + }); + } } } diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts index e3112174b..79d918018 100644 --- a/packages/taler-wallet-core/src/wallet-api-types.ts +++ b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -42,6 +42,8 @@ import { BalancesResponse, CanonicalizeBaseUrlRequest, CanonicalizeBaseUrlResponse, + CheckDepositRequest, + CheckDepositResponse, CheckPayTemplateReponse, CheckPayTemplateRequest, CheckPeerPullCreditRequest, @@ -64,12 +66,10 @@ import { EmptyObject, ExchangeDetailedResponse, ExchangesListResponse, - ExchangesShortListResponse, FailTransactionRequest, ForceRefreshRequest, ForgetKnownBankAccountsRequest, GetActiveTasksResponse, - GetAmountRequest, GetBalanceDetailRequest, GetBankingChoicesForPaytoRequest, GetBankingChoicesForPaytoResponse, @@ -84,10 +84,13 @@ import { GetExchangeResourcesResponse, GetExchangeTosRequest, GetExchangeTosResult, - GetPlanForOperationRequest, - GetPlanForOperationResponse, + GetMaxDepositAmountRequest, + GetMaxDepositAmountResponse, + GetMaxPeerPushDebitAmountRequest, + GetMaxPeerPushDebitAmountResponse, GetQrCodesForPaytoRequest, GetQrCodesForPaytoResponse, + GetTransactionsV2Request, GetWithdrawalDetailsForAmountRequest, GetWithdrawalDetailsForUriRequest, HintNetworkAvailabilityRequest, @@ -103,14 +106,12 @@ import { KnownBankAccounts, ListAssociatedRefreshesRequest, ListAssociatedRefreshesResponse, - ListExchangesForScopedCurrencyRequest, + ListExchangesRequest, ListGlobalCurrencyAuditorsResponse, ListGlobalCurrencyExchangesResponse, ListKnownBankAccountsRequest, PrepareBankIntegratedWithdrawalRequest, PrepareBankIntegratedWithdrawalResponse, - PrepareDepositRequest, - PrepareDepositResponse, PreparePayRequest, PreparePayResult, PreparePayTemplateRequest, @@ -186,6 +187,7 @@ export enum WalletApiOperation { TestPay = "testPay", AddExchange = "addExchange", GetTransactions = "getTransactions", + GetTransactionsV2 = "getTransactionsV2", GetTransactionById = "getTransactionById", TestingGetSampleTransactions = "testingGetSampleTransactions", ListExchanges = "listExchanges", @@ -198,12 +200,9 @@ export enum WalletApiOperation { AcceptManualWithdrawal = "acceptManualWithdrawal", GetBalances = "getBalances", GetBalanceDetail = "getBalanceDetail", - GetPlanForOperation = "getPlanForOperation", ConvertDepositAmount = "convertDepositAmount", GetMaxDepositAmount = "getMaxDepositAmount", - ConvertPeerPushAmount = "ConvertPeerPushAmount", - GetMaxPeerPushAmount = "getMaxPeerPushAmount", - ConvertWithdrawalAmount = "convertWithdrawalAmount", + GetMaxPeerPushDebitAmount = "getMaxPeerPushDebitAmount", GetUserAttentionRequests = "getUserAttentionRequests", GetUserAttentionUnreadCount = "getUserAttentionUnreadCount", MarkAttentionRequestAsRead = "markAttentionRequestAsRead", @@ -235,7 +234,7 @@ export enum WalletApiOperation { ExportBackupRecovery = "exportBackupRecovery", ImportBackupRecovery = "importBackupRecovery", GetBackupInfo = "getBackupInfo", - PrepareDeposit = "prepareDeposit", + CheckDeposit = "checkDeposit", GetVersion = "getVersion", GenerateDepositGroupTxId = "generateDepositGroupTxId", CreateDepositGroup = "createDepositGroup", @@ -260,7 +259,6 @@ export enum WalletApiOperation { DeleteStoredBackup = "deleteStoredBackup", RecoverStoredBackup = "recoverStoredBackup", UpdateExchangeEntry = "updateExchangeEntry", - ListExchangesForScopedCurrency = "listExchangesForScopedCurrency", PrepareWithdrawExchange = "prepareWithdrawExchange", GetExchangeResources = "getExchangeResources", DeleteExchange = "deleteExchange", @@ -289,6 +287,12 @@ export enum WalletApiOperation { TestingResetAllRetries = "testingResetAllRetries", StartExchangeWalletKyc = "startExchangeWalletKyc", TestingWaitExchangeWalletKyc = "testingWaitWalletKyc", + HintApplicationResumed = "hintApplicationResumed", + + /** + * @deprecated use checkDeposit instead + */ + PrepareDeposit = "prepareDeposit", } // group: Initialization @@ -311,6 +315,17 @@ export type ShutdownOp = { }; /** + * Give wallet-core a kick and restart all pending tasks. + * Useful when the host application gets suspended and resumed, + * and active network requests might have stalled. + */ +export type HintApplicationResumedOp = { + op: WalletApiOperation.HintApplicationResumed; + request: EmptyObject; + response: EmptyObject; +}; + +/** * Change the configuration of wallet-core. * * Currently an alias for the initWallet request. @@ -349,36 +364,22 @@ export type GetBalancesDetailOp = { response: PaymentBalanceDetails; }; -export type GetPlanForOperationOp = { - op: WalletApiOperation.GetPlanForOperation; - request: GetPlanForOperationRequest; - response: GetPlanForOperationResponse; -}; - export type ConvertDepositAmountOp = { op: WalletApiOperation.ConvertDepositAmount; request: ConvertAmountRequest; response: AmountResponse; }; + export type GetMaxDepositAmountOp = { op: WalletApiOperation.GetMaxDepositAmount; - request: GetAmountRequest; - response: AmountResponse; + request: GetMaxDepositAmountRequest; + response: GetMaxDepositAmountResponse; }; -export type ConvertPeerPushAmountOp = { - op: WalletApiOperation.ConvertPeerPushAmount; - request: ConvertAmountRequest; - response: AmountResponse; -}; -export type GetMaxPeerPushAmountOp = { - op: WalletApiOperation.GetMaxPeerPushAmount; - request: GetAmountRequest; - response: AmountResponse; -}; -export type ConvertWithdrawalAmountOp = { - op: WalletApiOperation.ConvertWithdrawalAmount; - request: ConvertAmountRequest; - response: AmountResponse; + +export type GetMaxPeerPushDebitAmountOp = { + op: WalletApiOperation.GetMaxPeerPushDebitAmount; + request: GetMaxPeerPushDebitAmountRequest; + response: GetMaxPeerPushDebitAmountResponse; }; // group: Managing Transactions @@ -392,6 +393,12 @@ export type GetTransactionsOp = { response: TransactionsResponse; }; +export type GetTransactionsV2Op = { + op: WalletApiOperation.GetTransactionsV2; + request: GetTransactionsV2Request; + response: TransactionsResponse; +}; + /** * List refresh transactions associated with another transaction. */ @@ -646,7 +653,7 @@ export type RemoveGlobalCurrencyAuditorOp = { */ export type ListExchangesOp = { op: WalletApiOperation.ListExchanges; - request: EmptyObject; + request: ListExchangesRequest; response: ExchangesListResponse; }; @@ -663,16 +670,6 @@ export type TestingWaitExchangeWalletKycOp = { }; /** - * List exchanges that are available for withdrawing a particular - * scoped currency. - */ -export type ListExchangesForScopedCurrencyOp = { - op: WalletApiOperation.ListExchangesForScopedCurrency; - request: ListExchangesForScopedCurrencyRequest; - response: ExchangesShortListResponse; -}; - -/** * Prepare for withdrawing via a taler://withdraw-exchange URI. */ export type PrepareWithdrawExchangeOp = { @@ -823,11 +820,19 @@ export type CreateDepositGroupOp = { response: CreateDepositGroupResponse; }; -// FIXME: Rename to checkDeposit, as it does not create a transaction, just computes fees! +export type CheckDepositOp = { + op: WalletApiOperation.CheckDeposit; + request: CheckDepositRequest; + response: CheckDepositResponse; +}; + +/** + * @deprecated use CheckDepositOp instead + */ export type PrepareDepositOp = { op: WalletApiOperation.PrepareDeposit; - request: PrepareDepositRequest; - response: PrepareDepositResponse; + request: CheckDepositRequest; + response: CheckDepositResponse; }; // group: Backups @@ -1297,12 +1302,10 @@ export type WalletOperations = { [WalletApiOperation.GetBalances]: GetBalancesOp; [WalletApiOperation.ConvertDepositAmount]: ConvertDepositAmountOp; [WalletApiOperation.GetMaxDepositAmount]: GetMaxDepositAmountOp; - [WalletApiOperation.ConvertPeerPushAmount]: ConvertPeerPushAmountOp; - [WalletApiOperation.GetMaxPeerPushAmount]: GetMaxPeerPushAmountOp; - [WalletApiOperation.ConvertWithdrawalAmount]: ConvertWithdrawalAmountOp; - [WalletApiOperation.GetPlanForOperation]: GetPlanForOperationOp; + [WalletApiOperation.GetMaxPeerPushDebitAmount]: GetMaxPeerPushDebitAmountOp; [WalletApiOperation.GetBalanceDetail]: GetBalancesDetailOp; [WalletApiOperation.GetTransactions]: GetTransactionsOp; + [WalletApiOperation.GetTransactionsV2]: GetTransactionsV2Op; [WalletApiOperation.TestingGetSampleTransactions]: TestingGetSampleTransactionsOp; [WalletApiOperation.GetTransactionById]: GetTransactionByIdOp; [WalletApiOperation.RetryPendingNow]: RetryPendingNowOp; @@ -1322,7 +1325,6 @@ export type WalletOperations = { [WalletApiOperation.AcceptBankIntegratedWithdrawal]: AcceptBankIntegratedWithdrawalOp; [WalletApiOperation.AcceptManualWithdrawal]: AcceptManualWithdrawalOp; [WalletApiOperation.ListExchanges]: ListExchangesOp; - [WalletApiOperation.ListExchangesForScopedCurrency]: ListExchangesForScopedCurrencyOp; [WalletApiOperation.AddExchange]: AddExchangeOp; [WalletApiOperation.ListKnownBankAccounts]: ListKnownBankAccountsOp; [WalletApiOperation.AddKnownBankAccounts]: AddKnownBankAccountsOp; @@ -1333,6 +1335,7 @@ export type WalletOperations = { [WalletApiOperation.GetExchangeDetailedInfo]: GetExchangeDetailedInfoOp; [WalletApiOperation.GetExchangeEntryByUrl]: GetExchangeEntryByUrlOp; [WalletApiOperation.PrepareDeposit]: PrepareDepositOp; + [WalletApiOperation.CheckDeposit]: CheckDepositOp; [WalletApiOperation.GenerateDepositGroupTxId]: GenerateDepositGroupTxIdOp; [WalletApiOperation.CreateDepositGroup]: CreateDepositGroupOp; [WalletApiOperation.SetWalletDeviceId]: SetWalletDeviceIdOp; @@ -1398,6 +1401,7 @@ export type WalletOperations = { [WalletApiOperation.GetBankingChoicesForPayto]: GetBankingChoicesForPaytoOp; [WalletApiOperation.StartExchangeWalletKyc]: StartExchangeWalletKycOp; [WalletApiOperation.TestingWaitExchangeWalletKyc]: TestingWaitExchangeWalletKycOp; + [WalletApiOperation.HintApplicationResumed]: HintApplicationResumedOp; }; export type WalletCoreRequestType< diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index a377d0795..cdc066d85 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -53,7 +53,6 @@ import { DenominationInfo, Duration, EmptyObject, - ExchangesShortListResponse, FailTransactionRequest, ForgetKnownBankAccountsRequest, GetActiveTasksResponse, @@ -75,7 +74,6 @@ import { IntegrationTestV2Args, KnownBankAccounts, KnownBankAccountsInfo, - ListExchangesForScopedCurrencyRequest, ListGlobalCurrencyAuditorsResponse, ListGlobalCurrencyExchangesResponse, ListKnownBankAccountsRequest, @@ -117,6 +115,7 @@ import { WalletCoreVersion, WalletNotification, WalletRunConfig, + WireTypeDetails, WithdrawTestBalanceRequest, canonicalizeBaseUrl, checkDbInvariant, @@ -132,6 +131,7 @@ import { codecForAny, codecForApplyDevExperiment, codecForCanonicalizeBaseUrlRequest, + codecForCheckDepositRequest, codecForCheckPayTemplateRequest, codecForCheckPeerPullPaymentRequest, codecForCheckPeerPushDebitRequest, @@ -147,7 +147,6 @@ import { codecForFailTransactionRequest, codecForForceRefreshRequest, codecForForgetKnownBankAccounts, - codecForGetAmountRequest, codecForGetBalanceDetailRequest, codecForGetBankingChoicesForPaytoRequest, codecForGetContractTermsDetails, @@ -156,7 +155,10 @@ import { codecForGetExchangeEntryByUrlRequest, codecForGetExchangeResourcesRequest, codecForGetExchangeTosRequest, + codecForGetMaxDepositAmountRequest, + codecForGetMaxPeerPushDebitAmountRequest, codecForGetQrCodesForPaytoRequest, + codecForGetTransactionsV2Request, codecForGetWithdrawalDetailsForAmountRequest, codecForGetWithdrawalDetailsForUri, codecForHintNetworkAvailabilityRequest, @@ -166,10 +168,9 @@ import { codecForInitiatePeerPushDebitRequest, codecForIntegrationTestArgs, codecForIntegrationTestV2Args, - codecForListExchangesForScopedCurrencyRequest, + codecForListExchangesRequest, codecForListKnownBankAccounts, codecForPrepareBankIntegratedWithdrawalRequest, - codecForPrepareDepositRequest, codecForPreparePayRequest, codecForPreparePayTemplateRequest, codecForPreparePeerPullPaymentRequest, @@ -232,6 +233,10 @@ import { setWalletDeviceId, } from "./backup/index.js"; import { getBalanceDetail, getBalances } from "./balance.js"; +import { + getMaxDepositAmount, + getMaxPeerPushDebitAmount, +} from "./coinSelection.js"; import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js"; import { CryptoDispatcher, @@ -274,14 +279,9 @@ import { handleTestingWaitExchangeWalletKyc, listExchanges, lookupExchangeByUri, + markExchangeUsed, } from "./exchanges.js"; -import { - convertDepositAmount, - convertPeerPushAmount, - convertWithdrawalAmount, - getMaxDepositAmount, - getMaxPeerPushAmount, -} from "./instructedAmountConversion.js"; +import { convertDepositAmount } from "./instructedAmountConversion.js"; import { ObservableDbAccess, ObservableTaskScheduler, @@ -338,11 +338,11 @@ import { } from "./testing.js"; import { abortTransaction, - constructTransactionIdentifier, deleteTransaction, failTransaction, getTransactionById, getTransactions, + getTransactionsV2, parseTransactionIdentifier, rematerializeTransactions, restartAll as restartAllRunningTasks, @@ -910,6 +910,11 @@ async function handleAddExchange( req: AddExchangeRequest, ): Promise<EmptyObject> { await fetchFreshExchange(wex, req.exchangeBaseUrl, {}); + // Exchange has been explicitly added upon user request. + // Thus, we mark it as "used". + await wex.db.runAllStoresReadWriteTx({}, async (tx) => { + await markExchangeUsed(wex, tx, req.exchangeBaseUrl); + }); return {}; } @@ -949,26 +954,6 @@ async function handleTestingGetDenomStats( return denomStats; } -async function handleListExchangesForScopedCurrency( - wex: WalletExecutionContext, - req: ListExchangesForScopedCurrencyRequest, -): Promise<ExchangesShortListResponse> { - const exchangesResp = await listExchanges(wex); - const result: ExchangesShortListResponse = { - exchanges: [], - }; - // Right now we only filter on the currency, as wallet-core doesn't - // fully support scoped currencies yet. - for (const exch of exchangesResp.exchanges) { - if (exch.currency === req.scope.currency) { - result.exchanges.push({ - exchangeBaseUrl: exch.exchangeBaseUrl, - }); - } - } - return result; -} - async function handleAddKnownBankAccount( wex: WalletExecutionContext, req: AddKnownBankAccountsRequest, @@ -1041,18 +1026,11 @@ async function handleGetContractTermsDetails( wex: WalletExecutionContext, req: GetContractTermsDetailsRequest, ): Promise<WalletContractData> { - if (req.proposalId) { - // FIXME: deprecated path - return getContractTermsDetails(wex, req.proposalId); - } - if (req.transactionId) { - const parsedTx = parseTransactionIdentifier(req.transactionId); - if (parsedTx?.tag === TransactionType.Payment) { - return getContractTermsDetails(wex, parsedTx.proposalId); - } + const parsedTx = parseTransactionIdentifier(req.transactionId); + if (parsedTx?.tag !== TransactionType.Payment) { throw Error("transactionId is not a payment transaction"); } - throw Error("transactionId missing"); + return getContractTermsDetails(wex, parsedTx.proposalId); } async function handleGetQrCodesForPayto( @@ -1112,19 +1090,7 @@ async function handleConfirmPay( wex: WalletExecutionContext, req: ConfirmPayRequest, ): Promise<ConfirmPayResult> { - let transactionId; - if (req.proposalId) { - // legacy client support - transactionId = constructTransactionIdentifier({ - tag: TransactionType.Payment, - proposalId: req.proposalId, - }); - } else if (req.transactionId) { - transactionId = req.transactionId; - } else { - throw Error("transactionId or (deprecated) proposalId required"); - } - return await confirmPay(wex, transactionId, req.sessionId); + return await confirmPay(wex, req.transactionId, req.sessionId); } async function handleAbortTransaction( @@ -1240,6 +1206,8 @@ async function handleGetDepositWireTypesForCurrency( req: GetDepositWireTypesForCurrencyRequest, ): Promise<GetDepositWireTypesForCurrencyResponse> { const wtSet: Set<string> = new Set(); + const wireTypeDetails: WireTypeDetails[] = []; + const talerBankHostnames: string[] = []; await wex.db.runReadOnlyTx( { storeNames: ["exchanges", "exchangeDetails"] }, async (tx) => { @@ -1261,19 +1229,36 @@ async function handleGetDepositWireTypesForCurrency( } } if (!usable) { - break; + continue; } const parsedPayto = parsePaytoUri(acc.payto_uri); if (!parsedPayto) { continue; } - wtSet.add(parsedPayto.targetType); + if ( + parsedPayto.isKnown && + parsedPayto.targetType === "x-taler-bank" + ) { + if (!talerBankHostnames.includes(parsedPayto.host)) { + talerBankHostnames.push(parsedPayto.host); + } + } + if (!wtSet.has(parsedPayto.targetType)) { + wtSet.add(parsedPayto.targetType); + wireTypeDetails.push({ + paymentTargetType: parsedPayto.targetType, + // Will possibly extended later by other exchanges + // with the same wire type. + talerBankHostnames, + }); + } } } }, ); return { wireTypes: [...wtSet], + wireTypeDetails, }; } @@ -1530,6 +1515,15 @@ async function handleGetCurrencySpecification( return defaultResp; } +export async function handleHintApplicationResumed( + wex: WalletExecutionContext, + req: EmptyObject, +): Promise<EmptyObject> { + logger.info("handling hintApplicationResumed"); + await restartAllRunningTasks(wex); + return {}; +} + async function handleGetVersion( wex: WalletExecutionContext, ): Promise<WalletCoreVersion> { @@ -1562,6 +1556,10 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = { codec: codecForAny(), handler: handleTestingWaitExchangeState, }, + [WalletApiOperation.HintApplicationResumed]: { + codec: codecForEmptyObject(), + handler: handleHintApplicationResumed, + }, [WalletApiOperation.AbortTransaction]: { codec: codecForAbortTransaction(), handler: handleAbortTransaction, @@ -1619,6 +1617,10 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = { codec: codecForTransactionsRequest(), handler: getTransactions, }, + [WalletApiOperation.GetTransactionsV2]: { + codec: codecForGetTransactionsV2Request(), + handler: getTransactionsV2, + }, [WalletApiOperation.GetTransactionById]: { codec: codecForTransactionByIdRequest(), handler: getTransactionById, @@ -1640,17 +1642,13 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = { handler: handleTestingGetDenomStats, }, [WalletApiOperation.ListExchanges]: { - codec: codecForEmptyObject(), + codec: codecForListExchangesRequest(), handler: listExchanges, }, [WalletApiOperation.GetExchangeEntryByUrl]: { codec: codecForGetExchangeEntryByUrlRequest(), handler: lookupExchangeByUri, }, - [WalletApiOperation.ListExchangesForScopedCurrency]: { - codec: codecForListExchangesForScopedCurrencyRequest(), - handler: handleListExchangesForScopedCurrency, - }, [WalletApiOperation.GetExchangeDetailedInfo]: { codec: codecForAddExchangeRequest(), handler: (wex, req) => getExchangeDetailedInfo(wex, req.exchangeBaseUrl), @@ -1864,27 +1862,23 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = { handler: convertDepositAmount, }, [WalletApiOperation.GetMaxDepositAmount]: { - codec: codecForGetAmountRequest, + codec: codecForGetMaxDepositAmountRequest, handler: getMaxDepositAmount, }, - [WalletApiOperation.ConvertPeerPushAmount]: { - codec: codecForConvertAmountRequest, - handler: convertPeerPushAmount, - }, - [WalletApiOperation.GetMaxPeerPushAmount]: { - codec: codecForGetAmountRequest, - handler: getMaxPeerPushAmount, - }, - [WalletApiOperation.ConvertWithdrawalAmount]: { - codec: codecForConvertAmountRequest, - handler: convertWithdrawalAmount, + [WalletApiOperation.GetMaxPeerPushDebitAmount]: { + codec: codecForGetMaxPeerPushDebitAmountRequest(), + handler: getMaxPeerPushDebitAmount, }, [WalletApiOperation.GetBackupInfo]: { codec: codecForEmptyObject(), handler: getBackupInfo, }, [WalletApiOperation.PrepareDeposit]: { - codec: codecForPrepareDepositRequest(), + codec: codecForCheckDepositRequest(), + handler: checkDepositGroup, + }, + [WalletApiOperation.CheckDeposit]: { + codec: codecForCheckDepositRequest(), handler: checkDepositGroup, }, [WalletApiOperation.GenerateDepositGroupTxId]: { @@ -2089,12 +2083,6 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = { return {}; }, }, - [WalletApiOperation.GetPlanForOperation]: { - codec: codecForAny(), - handler: async (wex, req) => { - throw Error("not implemented"); - }, - }, [WalletApiOperation.ExportBackup]: { codec: codecForAny(), handler: async (wex, req) => { diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts index a7d0d1ce1..080e915d0 100644 --- a/packages/taler-wallet-core/src/withdraw.ts +++ b/packages/taler-wallet-core/src/withdraw.ts @@ -33,7 +33,6 @@ import { AmountLike, AmountString, Amounts, - AsyncFlag, BankWithdrawDetails, CancellationToken, CoinStatus, @@ -85,6 +84,7 @@ import { WithdrawalType, addPaytoQueryParams, assertUnreachable, + checkAccountRestriction, checkDbInvariant, checkLogicInvariant, codecForAccountKycStatus, @@ -93,14 +93,15 @@ import { codecForCashinConversionResponse, codecForConversionBankConfig, codecForExchangeWithdrawBatchResponse, + codecForLegitimizationNeededResponse, codecForReserveStatus, - codecForWalletKycUuid, codecForWithdrawOperationStatusResponse, encodeCrock, getErrorDetailFromException, getRandomBytes, j2s, makeErrorDetail, + parsePaytoUri, parseTalerUri, parseWithdrawUri, } from "@gnu-taler/taler-util"; @@ -129,13 +130,12 @@ import { requireExchangeTosAcceptedOrThrow, runWithClientCancellation, } from "./common.js"; -import { EddsaKeypair } from "./crypto/cryptoImplementation.js"; +import { EddsaKeyPairStrings } from "./crypto/cryptoImplementation.js"; import { CoinRecord, CoinSourceType, DenominationRecord, DenominationVerificationStatus, - KycPendingInfo, OperationRetryRecord, PlanchetRecord, PlanchetStatus, @@ -166,12 +166,17 @@ import { fetchFreshExchange, getExchangePaytoUri, getExchangeWireDetailsInTx, + getPreferredExchangeForScope, getScopeForAllExchanges, handleStartExchangeWalletKyc, listExchanges, lookupExchangeByUri, markExchangeUsed, } from "./exchanges.js"; +import { + checkWithdrawalHardLimitExceeded, + getWithdrawalLimitInfo, +} from "./kyc.js"; import { DbAccess } from "./query.js"; import { TransitionInfo, @@ -241,9 +246,11 @@ function buildTransactionForBankIntegratedWithdraw( wg.status === WithdrawalGroupStatus.Done || wg.status === WithdrawalGroupStatus.PendingReady, }, - kycUrl: wg.kycUrl, + kycUrl: kycDetails?.kycUrl, kycAccessToken: wg.kycAccessToken, - kycPaytoHash: wg.kycPending?.paytoHash, + kycPaytoHash: wg.kycPaytoHash, + abortReason: wg.abortReason, + failReason: wg.failReason, timestamp: timestampPreciseFromDb(wg.timestampStart), transactionId: constructTransactionIdentifier({ tag: TransactionType.Withdrawal, @@ -302,13 +309,14 @@ function buildTransactionForManualWithdraw( wg.status === WithdrawalGroupStatus.PendingReady, reserveClosingDelay: exchangeDetails?.reserveClosingDelay ?? { d_us: 0 }, }, - kycUrl: wg.kycUrl, + kycUrl: kycDetails?.kycUrl, exchangeBaseUrl: wg.exchangeBaseUrl, timestamp: timestampPreciseFromDb(wg.timestampStart), transactionId: constructTransactionIdentifier({ tag: TransactionType.Withdrawal, withdrawalGroupId: wg.withdrawalGroupId, }), + abortReason: wg.abortReason, ...(ort?.lastError ? { error: ort.lastError } : {}), }; if (ort?.lastError) { @@ -365,17 +373,21 @@ export class WithdrawTransactionContext implements TransactionContext { let kycDetails: TxKycDetails | undefined = undefined; - switch (withdrawalGroupRecord.status) { - case WithdrawalGroupStatus.PendingKyc: - case WithdrawalGroupStatus.SuspendedKyc: { - kycDetails = { - kycAccessToken: withdrawalGroupRecord.kycAccessToken, - kycPaytoHash: withdrawalGroupRecord.kycPending?.paytoHash, - kycUrl: withdrawalGroupRecord.kycUrl, - }; - break; + if (exchangeBaseUrl) { + switch (withdrawalGroupRecord.status) { + case WithdrawalGroupStatus.PendingKyc: + case WithdrawalGroupStatus.SuspendedKyc: { + kycDetails = { + kycAccessToken: withdrawalGroupRecord.kycAccessToken, + kycPaytoHash: withdrawalGroupRecord.kycPaytoHash, + kycUrl: new URL( + `kyc-spa/${withdrawalGroupRecord.kycAccessToken}`, + exchangeBaseUrl, + ).href, + }; + break; + } } - // For the balance KYC, the client should get the kycUrl etc. from the exchange entry! } const ort = await tx.operationRetries.get(this.taskId); @@ -605,7 +617,7 @@ export class WithdrawTransactionContext implements TransactionContext { ); } - async abortTransaction(): Promise<void> { + async abortTransaction(reason?: TalerErrorDetail): Promise<void> { const { withdrawalGroupId } = this; await this.transition( { @@ -657,6 +669,7 @@ export class WithdrawTransactionContext implements TransactionContext { default: assertUnreachable(wg.status); } + wg.abortReason = reason; wg.status = newStatus; return TransitionResult.transition(wg); }, @@ -706,7 +719,7 @@ export class WithdrawTransactionContext implements TransactionContext { ); } - async failTransaction(): Promise<void> { + async failTransaction(reason?: TalerErrorDetail): Promise<void> { const { withdrawalGroupId } = this; await this.transition( { @@ -727,6 +740,7 @@ export class WithdrawTransactionContext implements TransactionContext { return TransitionResult.stay(); } wg.status = newStatus; + wg.failReason = reason; return TransitionResult.transition(wg); }, ); @@ -916,8 +930,8 @@ export function computeWithdrawalTransactionActions( return [TransactionAction.Resume, TransactionAction.Abort]; case WithdrawalGroupStatus.PendingKyc: return [ + TransactionAction.Suspend, TransactionAction.Retry, - TransactionAction.Resume, TransactionAction.Abort, ]; case WithdrawalGroupStatus.SuspendedKyc: @@ -1027,13 +1041,8 @@ async function processWithdrawalGroupBalanceKyc( return TransitionResult.stay(); } wg.status = WithdrawalGroupStatus.PendingBalanceKyc; - wg.kycPending = undefined; wg.kycAccessToken = ret.walletKycAccessToken; - // FIXME: #9109 this should not be constructed here, it should be an opaque URL from exchange response - wg.kycUrl = new URL( - `kyc-spa/${ret.walletKycAccessToken}`, - exchangeBaseUrl, - ).href; + delete wg.kycPaytoHash; return TransitionResult.transition(wg); }); return TaskRunResult.progress(); @@ -1149,7 +1158,13 @@ export async function getBankWithdrawalInfo( ); if (resp.type === "fail") { - throw TalerError.fromUncheckedDetail(resp.detail); + if (resp.detail) { + throw TalerError.fromUncheckedDetail(resp.detail); + } else { + throw TalerError.fromException( + new Error("failed to get bank remote config"), + ); + } } const { body: status } = resp; @@ -1343,13 +1358,6 @@ interface WithdrawalBatchResult { batchResp: ExchangeWithdrawBatchResponse; } -// FIXME: Move to exchange API types -enum ExchangeAmlStatus { - Normal = 0, - Pending = 1, - Frozen = 2, -} - /** * Transition a transaction from pending(ready) * into a pending(kyc|aml) state, in case KYC is required. @@ -1363,20 +1371,17 @@ async function handleKycRequired( ): Promise<void> { logger.info("withdrawal requires KYC"); const respJson = await resp.json(); - const uuidResp = codecForWalletKycUuid().decode(respJson); + const legiRequiredResp = + codecForLegitimizationNeededResponse().decode(respJson); const withdrawalGroupId = withdrawalGroup.withdrawalGroupId; const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId); - logger.info(`kyc uuid response: ${j2s(uuidResp)}`); + logger.info(`kyc uuid response: ${j2s(legiRequiredResp)}`); const exchangeUrl = withdrawalGroup.exchangeBaseUrl; - const kycInfo: KycPendingInfo = { - paytoHash: uuidResp.h_payto, - requirementRow: uuidResp.requirement_row, - }; const sigResp = await wex.cryptoApi.signWalletKycAuth({ accountPriv: withdrawalGroup.reservePriv, accountPub: withdrawalGroup.reservePub, }); - const url = new URL(`kyc-check/${kycInfo.requirementRow}`, exchangeUrl); + const url = new URL(`kyc-check/${legiRequiredResp.h_payto}`, exchangeUrl); logger.info(`kyc url ${url.href}`); // We do not longpoll here, as this is the initial request to get information about the KYC. const kycStatusRes = await wex.http.fetch(url.href, { @@ -1430,16 +1435,7 @@ async function handleKycRequired( if (wg2.status !== WithdrawalGroupStatus.PendingReady) { return TransitionResult.stay(); } - // FIXME: Why not just store the whole kycState?! - wg2.kycPending = { - paytoHash: uuidResp.h_payto, - requirementRow: uuidResp.requirement_row, - }; - // FIXME: #9109 this should not be constructed here, it should be an opaque URL from exchange response - wg2.kycUrl = new URL( - `kyc-spa/${kycStatus.access_token}`, - exchangeUrl, - ).href; + wg2.kycPaytoHash = legiRequiredResp.h_payto; wg2.kycAccessToken = kycStatus.access_token; wg2.status = WithdrawalGroupStatus.PendingKyc; return TransitionResult.transition(wg2); @@ -1917,8 +1913,8 @@ async function processQueryReserve( ) { amountChanged = true; } - console.log(`amount change ${j2s(result.response)}`); - console.log( + logger.trace(`amount change ${j2s(result.response)}`); + logger.trace( `amount change ${j2s(withdrawalGroup.denomsSel.totalWithdrawCost)}`, ); @@ -2015,12 +2011,12 @@ async function processWithdrawalGroupPendingKyc( wex, withdrawalGroup.withdrawalGroupId, ); - const kycInfo = withdrawalGroup.kycPending; - if (!kycInfo) { + const kycPaytoHash = withdrawalGroup.kycPaytoHash; + if (!kycPaytoHash) { throw Error("no kyc info available in pending(kyc)"); } const exchangeUrl = withdrawalGroup.exchangeBaseUrl; - const url = new URL(`kyc-check/${kycInfo.requirementRow}`, exchangeUrl); + const url = new URL(`kyc-check/${kycPaytoHash}`, exchangeUrl); const sigResp = await wex.cryptoApi.signWalletKycAuth({ accountPriv: withdrawalGroup.reservePriv, accountPub: withdrawalGroup.reservePub, @@ -2052,8 +2048,8 @@ async function processWithdrawalGroupPendingKyc( } switch (rec.status) { case WithdrawalGroupStatus.PendingKyc: { - delete rec.kycPending; - delete rec.kycUrl; + delete rec.kycAccessToken; + delete rec.kycAccessToken; rec.status = WithdrawalGroupStatus.PendingReady; return TransitionResult.transition(rec); } @@ -2205,14 +2201,6 @@ async function redenominateWithdrawal( .toString(), hasDenomWithAgeRestriction: prevHasDenomWithAgeRestriction || newSel.hasDenomWithAgeRestriction, - earliestDepositExpiration: AbsoluteTime.toProtocolTimestamp( - AbsoluteTime.min( - prevEarliestDepositExpiration, - AbsoluteTime.fromProtocolTimestamp( - newSel.earliestDepositExpiration, - ), - ), - ), }; wg.denomsSel = mergedSel; if (logger.shouldLogTrace()) { @@ -2411,11 +2399,6 @@ async function processWithdrawalGroupPendingReady( throw Error("withdrawal group does not exist anymore"); } - wex.ws.notify({ - type: NotificationType.BalanceChange, - hintTransactionId: ctx.transactionId, - }); - if (numPlanchetErrors > 0) { return { type: TaskRunResultType.Error, @@ -2462,7 +2445,7 @@ export async function processWithdrawalGroup( case WithdrawalGroupStatus.PendingWaitConfirmBank: return await processReserveBankStatus(wex, withdrawalGroupId); case WithdrawalGroupStatus.PendingKyc: - return processWithdrawalGroupPendingKyc(wex, withdrawalGroup); + return await processWithdrawalGroupPendingKyc(wex, withdrawalGroup); case WithdrawalGroupStatus.PendingReady: // Continue with the actual withdrawal! return await processWithdrawalGroupPendingReady(wex, withdrawalGroup); @@ -2588,15 +2571,13 @@ export async function getExchangeWithdrawalInfo( throw Error("exchange is in invalid state"); } + logger.info(`exchange ready summary: ${j2s(exchange)}`); + const ret: ExchangeWithdrawalDetails = { - earliestDepositExpiration: selectedDenoms.earliestDepositExpiration, exchangePaytoUris: paytoUris, exchangeWireAccounts, exchangeCreditAccountDetails: withdrawalAccountsList, - exchangeVersion: exchange.protocolVersionRange || "unknown", selectedDenoms, - versionMatch, - walletVersion: WALLET_EXCHANGE_PROTOCOL_VERSION, termsOfServiceAccepted: tosAccepted, withdrawalAmountEffective: Amounts.stringify(selectedDenoms.totalCoinValue), withdrawalAmountRaw: Amounts.stringify(instructedAmount), @@ -2606,15 +2587,11 @@ export async function getExchangeWithdrawalInfo( ? AGE_MASK_GROUPS : undefined, scopeInfo: exchange.scopeInfo, + ...getWithdrawalLimitInfo(exchange, instructedAmount), }; return ret; } -export interface GetWithdrawalDetailsForUriOpts { - restrictAge?: number; - notifyChangeFromPendingTimeoutMs?: number; -} - async function getWithdrawalDetailsForBankInfo( wex: WalletExecutionContext, info: BankWithdrawDetails, @@ -2642,7 +2619,13 @@ async function getWithdrawalDetailsForBankInfo( }); possibleExchanges = [ex]; } else { - const listExchangesResp = await listExchanges(wex); + const listExchangesResp = await listExchanges(wex, {}); + + for (const exchange of listExchangesResp.exchanges) { + if (exchange.currency !== currency) { + continue; + } + } possibleExchanges = listExchangesResp.exchanges.filter((x) => { return ( @@ -2698,6 +2681,19 @@ export function augmentPaytoUrisForWithdrawal( ); } +export function augmentPaytoUrisForKycTransfer( + plainPaytoUris: string[], + reservePub: string, + tinyAmount: AmountLike, +): string[] { + return plainPaytoUris.map((x) => + addPaytoQueryParams(x, { + amount: Amounts.stringify(tinyAmount), + message: `Taler KYC ${reservePub}`, + }), + ); +} + /** * Get payto URIs that can be used to fund a withdrawal operation. */ @@ -3072,7 +3068,7 @@ export async function internalPrepareCreateWithdrawalGroup( exchangeBaseUrl: string | undefined; forcedWithdrawalGroupId?: string; forcedDenomSel?: ForcedDenomSel; - reserveKeyPair?: EddsaKeypair; + reserveKeyPair?: EddsaKeyPairStrings; restrictAge?: number; wgInfo: WgInfo; }, @@ -3136,7 +3132,6 @@ export async function internalPrepareCreateWithdrawalGroup( status: args.reserveStatus, withdrawalGroupId, restrictAge: args.restrictAge, - senderWire: undefined, timestampFinish: undefined, wgInfo: args.wgInfo, }; @@ -3268,7 +3263,7 @@ export async function internalCreateWithdrawalGroup( amount?: AmountJson; forcedWithdrawalGroupId?: string; forcedDenomSel?: ForcedDenomSel; - reserveKeyPair?: EddsaKeypair; + reserveKeyPair?: EddsaKeyPairStrings; restrictAge?: number; wgInfo: WgInfo; }, @@ -3367,6 +3362,7 @@ export async function prepareBankIntegratedWithdrawal( timestampReserveInfoPosted: undefined, wireTypes: withdrawInfo.wireTypes, currency: withdrawInfo.currency, + senderWire: withdrawInfo.senderWire, externalConfirmation, }, }, @@ -3416,6 +3412,10 @@ export async function confirmWithdrawal( const exchange = await fetchFreshExchange(wex, selectedExchange); requireExchangeTosAcceptedOrThrow(exchange); + if (checkWithdrawalHardLimitExceeded(exchange, req.amount)) { + throw Error("withdrawal would exceed hard KYC limit"); + } + const talerWithdrawUri = withdrawalGroup.wgInfo.bankInfo.talerWithdrawUri; /** @@ -3439,6 +3439,10 @@ export async function confirmWithdrawal( bankCurrency = withdrawalGroup.wgInfo.bankInfo.currency; } + if (exchange.currency !== bankCurrency) { + throw Error("currency mismatch between exchange and bank"); + } + const exchangePaytoUri = await getExchangePaytoUri( wex, selectedExchange, @@ -3454,6 +3458,35 @@ export async function confirmWithdrawal( wex.cancellationToken, ); + const senderWire = withdrawalGroup.wgInfo.bankInfo.senderWire; + + if (senderWire) { + logger.info(`sender wire is ${senderWire}`); + const parsedSenderWire = parsePaytoUri(senderWire); + if (!parsedSenderWire) { + throw Error("invalid payto URI"); + } + let acceptable = false; + for (const acc of withdrawalAccountList) { + const parsedExchangeWire = parsePaytoUri(acc.paytoUri); + if (!parsedExchangeWire) { + continue; + } + const checkRes = checkAccountRestriction( + senderWire, + acc.creditRestrictions ?? [], + ); + if (!checkRes.ok) { + continue; + } + acceptable = true; + break; + } + if (!acceptable) { + throw Error("bank account not acceptable by the exchange"); + } + } + const ctx = new WithdrawTransactionContext( wex, withdrawalGroup.withdrawalGroupId, @@ -3508,11 +3541,6 @@ export async function confirmWithdrawal( await wex.taskScheduler.resetTaskRetries(ctx.taskId); - wex.ws.notify({ - type: NotificationType.BalanceChange, - hintTransactionId: ctx.transactionId, - }); - const res = await wex.db.runReadWriteTx( { storeNames: ["exchanges"], @@ -3776,7 +3804,7 @@ async function fetchAccount( }); if (reservePub != null) { paytoUri = addPaytoQueryParams(paytoUri, { - message: `Taler-> ${reservePub}`, + message: `Taler ${reservePub}`, }); } const acctInfo: WithdrawalExchangeAccountDetails = { @@ -3858,7 +3886,11 @@ export async function createManualWithdrawal( ); } - let reserveKeyPair: EddsaKeypair; + if (checkWithdrawalHardLimitExceeded(exchange, req.amount)) { + throw Error("withdrawal would exceed hard KYC limit"); + } + + let reserveKeyPair: EddsaKeyPairStrings; if (req.forceReservePriv) { const pubResp = await wex.cryptoApi.eddsaGetPublic({ priv: req.forceReservePriv, @@ -3923,7 +3955,7 @@ export async function createManualWithdrawal( } /** - * Wait until a refresh operation is final. + * Wait until a withdrawal operation is final. */ export async function waitWithdrawalFinal( wex: WalletExecutionContext, @@ -3932,68 +3964,41 @@ export async function waitWithdrawalFinal( const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId); wex.taskScheduler.startShepherdTask(ctx.taskId); - // FIXME: Clean up using the new JS "using" / Symbol.dispose syntax. - const withdrawalNotifFlag = new AsyncFlag(); - // Raise purchaseNotifFlag whenever we get a notification - // about our refresh. - const cancelNotif = wex.ws.addNotificationListener((notif) => { - if ( - notif.type === NotificationType.TransactionStateTransition && - notif.transactionId === ctx.transactionId - ) { - withdrawalNotifFlag.raise(); - } - }); - const unregisterOnCancelled = wex.cancellationToken.onCancelled(() => { - cancelNotif(); - withdrawalNotifFlag.raise(); - }); - - try { - await internalWaitWithdrawalFinal(ctx, withdrawalNotifFlag); - } catch (e) { - unregisterOnCancelled(); - cancelNotif(); - } -} - -async function internalWaitWithdrawalFinal( - ctx: WithdrawTransactionContext, - flag: AsyncFlag, -): Promise<void> { - while (true) { - if (ctx.wex.cancellationToken.isCancelled) { - throw Error("cancelled"); - } - - // Check if refresh is final - const res = await ctx.wex.db.runReadOnlyTx( - { storeNames: ["withdrawalGroups"] }, - async (tx) => { - return { - wg: await tx.withdrawalGroups.get(ctx.withdrawalGroupId), - }; - }, - ); - const { wg } = res; - if (!wg) { - // Must've been deleted, we consider that final. - return; - } - switch (wg.status) { - case WithdrawalGroupStatus.AbortedBank: - case WithdrawalGroupStatus.AbortedExchange: - case WithdrawalGroupStatus.Done: - case WithdrawalGroupStatus.FailedAbortingBank: - case WithdrawalGroupStatus.FailedBankAborted: - // Transaction is final - return; - } + await genericWaitForState(wex, { + filterNotification(notif) { + return ( + notif.type === NotificationType.TransactionStateTransition && + notif.transactionId === ctx.transactionId + ); + }, + async checkState() { + // Check if withdrawal is final + const res = await ctx.wex.db.runReadOnlyTx( + { storeNames: ["withdrawalGroups"] }, + async (tx) => { + return { + wg: await tx.withdrawalGroups.get(ctx.withdrawalGroupId), + }; + }, + ); + const { wg } = res; + if (!wg) { + // Must've been deleted, we consider that final. + return true; + } + switch (wg.status) { + case WithdrawalGroupStatus.AbortedBank: + case WithdrawalGroupStatus.AbortedExchange: + case WithdrawalGroupStatus.Done: + case WithdrawalGroupStatus.FailedAbortingBank: + case WithdrawalGroupStatus.FailedBankAborted: + // Transaction is final + return true; + } - // Wait for the next transition - await flag.wait(); - flag.reset(); - } + return false; + }, + }); } export async function getWithdrawalDetailsForAmount( @@ -4008,13 +4013,25 @@ export async function getWithdrawalDetailsForAmount( ); } -async function internalGetWithdrawalDetailsForAmount( +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, ); @@ -4023,6 +4040,7 @@ async function internalGetWithdrawalDetailsForAmount( numCoins += x.count; } const resp: WithdrawalDetailsForAmount = { + exchangeBaseUrl, amountRaw: req.amount, amountEffective: Amounts.stringify(wi.selectedDenoms.totalCoinValue), paytoUris: wi.exchangePaytoUris, @@ -4031,6 +4049,8 @@ async function internalGetWithdrawalDetailsForAmount( withdrawalAccountsList: wi.exchangeCreditAccountDetails, numCoins, scopeInfo: wi.scopeInfo, + kycHardLimit: wi.kycHardLimit, + kycSoftLimit: wi.kycSoftLimit, }; return resp; } |