From 526f4eba9554f27e33afb0e02d19d870825b038c Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Sat, 8 Oct 2022 20:56:57 +0200 Subject: wallet-core: Clean up merchant payments DB schema --- .../src/operations/backup/export.ts | 95 +- .../src/operations/backup/import.ts | 201 +- .../src/operations/backup/index.ts | 11 +- .../taler-wallet-core/src/operations/common.ts | 292 ++- .../taler-wallet-core/src/operations/deposits.ts | 5 +- .../taler-wallet-core/src/operations/exchanges.ts | 3 - .../taler-wallet-core/src/operations/merchants.ts | 4 +- .../src/operations/pay-merchant.ts | 2758 ++++++++++++++++++++ .../taler-wallet-core/src/operations/pay-peer.ts | 847 ++++++ packages/taler-wallet-core/src/operations/pay.ts | 1893 -------------- .../src/operations/peer-to-peer.ts | 848 ------ .../taler-wallet-core/src/operations/pending.ts | 85 +- .../taler-wallet-core/src/operations/recoup.ts | 7 +- .../taler-wallet-core/src/operations/refresh.ts | 2 +- .../taler-wallet-core/src/operations/refund.ts | 815 ------ .../taler-wallet-core/src/operations/testing.ts | 5 +- packages/taler-wallet-core/src/operations/tip.ts | 5 +- .../src/operations/transactions.ts | 147 +- .../taler-wallet-core/src/operations/withdraw.ts | 14 +- 19 files changed, 4040 insertions(+), 3997 deletions(-) create mode 100644 packages/taler-wallet-core/src/operations/pay-merchant.ts create mode 100644 packages/taler-wallet-core/src/operations/pay-peer.ts delete mode 100644 packages/taler-wallet-core/src/operations/pay.ts delete mode 100644 packages/taler-wallet-core/src/operations/peer-to-peer.ts delete mode 100644 packages/taler-wallet-core/src/operations/refund.ts (limited to 'packages/taler-wallet-core/src/operations') diff --git a/packages/taler-wallet-core/src/operations/backup/export.ts b/packages/taler-wallet-core/src/operations/backup/export.ts index c8454a62f..04fac7d38 100644 --- a/packages/taler-wallet-core/src/operations/backup/export.ts +++ b/packages/taler-wallet-core/src/operations/backup/export.ts @@ -37,7 +37,7 @@ import { BackupExchangeDetails, BackupExchangeWireFee, BackupOperationStatus, - BackupProposal, + BackupPayInfo, BackupProposalStatus, BackupPurchase, BackupRecoupGroup, @@ -62,11 +62,9 @@ import { WalletBackupContentV1, } from "@gnu-taler/taler-util"; import { - AbortStatus, CoinSourceType, CoinStatus, DenominationRecord, - OperationStatus, ProposalStatus, RefreshCoinStatus, RefundState, @@ -92,7 +90,6 @@ export async function exportBackup( x.coins, x.denominations, x.purchases, - x.proposals, x.refreshGroups, x.backupProviders, x.tips, @@ -109,7 +106,6 @@ export async function exportBackup( [url: string]: BackupDenomination[]; } = {}; const backupPurchases: BackupPurchase[] = []; - const backupProposals: BackupProposal[] = []; const backupRefreshGroups: BackupRefreshGroup[] = []; const backupBackupProviders: BackupBackupProvider[] = []; const backupTips: BackupTip[] = []; @@ -385,65 +381,61 @@ export async function exportBackup( } } - backupPurchases.push({ - contract_terms_raw: purch.download.contractTermsRaw, - auto_refund_deadline: purch.autoRefundDeadline, - merchant_pay_sig: purch.merchantPaySig, - pay_coins: purch.payCoinSelection.coinPubs.map((x, i) => ({ - coin_pub: x, - contribution: Amounts.stringify( - purch.payCoinSelection.coinContributions[i], - ), - })), - proposal_id: purch.proposalId, - refunds, - timestamp_accept: purch.timestampAccept, - timestamp_first_successful_pay: purch.timestampFirstSuccessfulPay, - abort_status: - purch.abortStatus === AbortStatus.None - ? undefined - : purch.abortStatus, - nonce_priv: purch.noncePriv, - merchant_sig: purch.download.contractData.merchantSig, - total_pay_cost: Amounts.stringify(purch.totalPayCost), - pay_coins_uid: purch.payCoinSelectionUid, - }); - }); - - await tx.proposals.iter().forEach((prop) => { - if (purchaseProposalIdSet.has(prop.proposalId)) { - return; - } let propStatus: BackupProposalStatus; - switch (prop.proposalStatus) { - case ProposalStatus.Accepted: + switch (purch.status) { + case ProposalStatus.Paid: + propStatus = BackupProposalStatus.Paid; return; - case ProposalStatus.Downloading: + case ProposalStatus.DownloadingProposal: case ProposalStatus.Proposed: propStatus = BackupProposalStatus.Proposed; break; - case ProposalStatus.PermanentlyFailed: + case ProposalStatus.ProposalDownloadFailed: propStatus = BackupProposalStatus.PermanentlyFailed; break; - case ProposalStatus.Refused: + case ProposalStatus.ProposalRefused: propStatus = BackupProposalStatus.Refused; break; - case ProposalStatus.Repurchase: + case ProposalStatus.RepurchaseDetected: propStatus = BackupProposalStatus.Repurchase; break; + default: + throw Error(); } - backupProposals.push({ - claim_token: prop.claimToken, - nonce_priv: prop.noncePriv, - proposal_id: prop.noncePriv, + + const payInfo = purch.payInfo; + let backupPayInfo: BackupPayInfo | undefined = undefined; + if (payInfo) { + backupPayInfo = { + pay_coins: payInfo.payCoinSelection.coinPubs.map((x, i) => ({ + coin_pub: x, + contribution: Amounts.stringify( + payInfo.payCoinSelection.coinContributions[i], + ), + })), + total_pay_cost: Amounts.stringify(payInfo.totalPayCost), + pay_coins_uid: payInfo.payCoinSelectionUid, + }; + } + + backupPurchases.push({ + contract_terms_raw: purch.download?.contractTermsRaw, + auto_refund_deadline: purch.autoRefundDeadline, + merchant_pay_sig: purch.merchantPaySig, + pay_info: backupPayInfo, + proposal_id: purch.proposalId, + refunds, + timestamp_accepted: purch.timestampAccept, + timestamp_first_successful_pay: purch.timestampFirstSuccessfulPay, + nonce_priv: purch.noncePriv, + merchant_sig: purch.download?.contractData.merchantSig, + claim_token: purch.claimToken, + merchant_base_url: purch.merchantBaseUrl, + order_id: purch.orderId, proposal_status: propStatus, - repurchase_proposal_id: prop.repurchaseProposalId, - timestamp: prop.timestamp, - contract_terms_raw: prop.download?.contractTermsRaw, - download_session_id: prop.downloadSessionId, - merchant_base_url: prop.merchantBaseUrl, - order_id: prop.orderId, - merchant_sig: prop.download?.contractData.merchantSig, + repurchase_proposal_id: purch.repurchaseProposalId, + download_session_id: purch.downloadSessionId, + timestamp_proposed: purch.timestamp, }); }); @@ -498,7 +490,6 @@ export async function exportBackup( wallet_root_pub: bs.walletRootPub, backup_providers: backupBackupProviders, current_device_id: bs.deviceId, - proposals: backupProposals, purchases: backupPurchases, recoup_groups: backupRecoupGroups, refresh_groups: backupRefreshGroups, diff --git a/packages/taler-wallet-core/src/operations/backup/import.ts b/packages/taler-wallet-core/src/operations/backup/import.ts index fb747ef1c..00dbf6fa8 100644 --- a/packages/taler-wallet-core/src/operations/backup/import.ts +++ b/packages/taler-wallet-core/src/operations/backup/import.ts @@ -21,8 +21,8 @@ import { BackupCoin, BackupCoinSourceType, BackupDenomSel, + BackupPayInfo, BackupProposalStatus, - BackupPurchase, BackupRefreshReason, BackupRefundState, BackupWgType, @@ -37,7 +37,6 @@ import { WireInfo, } from "@gnu-taler/taler-util"; import { - AbortStatus, CoinRecord, CoinSource, CoinSourceType, @@ -48,28 +47,23 @@ import { OperationStatus, ProposalDownload, ProposalStatus, + PurchasePayInfo, RefreshCoinStatus, RefreshSessionRecord, RefundState, - ReserveBankInfo, - WithdrawalGroupStatus, WalletContractData, WalletRefundItem, WalletStoresV1, WgInfo, + WithdrawalGroupStatus, WithdrawalRecordType, } from "../../db.js"; import { InternalWalletState } from "../../internal-wallet-state.js"; import { assertUnreachable } from "../../util/assertUnreachable.js"; -import { - checkDbInvariant, - checkLogicInvariant, -} from "../../util/invariants.js"; +import { checkLogicInvariant } from "../../util/invariants.js"; import { GetReadOnlyAccess, GetReadWriteAccess } from "../../util/query.js"; -import { RetryInfo } from "../../util/retries.js"; -import { makeCoinAvailable } from "../../wallet.js"; +import { makeCoinAvailable, makeEventId, TombstoneTag } from "../common.js"; import { getExchangeDetails } from "../exchanges.js"; -import { makeEventId, TombstoneTag } from "../transactions.js"; import { provideBackupState } from "./state.js"; const logger = new Logger("operations/backup/import.ts"); @@ -95,10 +89,10 @@ async function recoverPayCoinSelection( denominations: typeof WalletStoresV1.denominations; }>, contractData: WalletContractData, - backupPurchase: BackupPurchase, + payInfo: BackupPayInfo, ): Promise { - const coinPubs: string[] = backupPurchase.pay_coins.map((x) => x.coin_pub); - const coinContributions: AmountJson[] = backupPurchase.pay_coins.map((x) => + const coinPubs: string[] = payInfo.pay_coins.map((x) => x.coin_pub); + const coinContributions: AmountJson[] = payInfo.pay_coins.map((x) => Amounts.parseOrThrow(x.contribution), ); @@ -316,7 +310,6 @@ export async function importBackup( x.coinAvailability, x.denominations, x.purchases, - x.proposals, x.refreshGroups, x.backupProviders, x.tips, @@ -560,113 +553,6 @@ export async function importBackup( } } - for (const backupProposal of backupBlob.proposals) { - const ts = makeEventId( - TombstoneTag.DeletePayment, - backupProposal.proposal_id, - ); - if (tombstoneSet.has(ts)) { - continue; - } - const existingProposal = await tx.proposals.get( - backupProposal.proposal_id, - ); - if (!existingProposal) { - let download: ProposalDownload | undefined; - let proposalStatus: ProposalStatus; - switch (backupProposal.proposal_status) { - case BackupProposalStatus.Proposed: - if (backupProposal.contract_terms_raw) { - proposalStatus = ProposalStatus.Proposed; - } else { - proposalStatus = ProposalStatus.Downloading; - } - break; - case BackupProposalStatus.Refused: - proposalStatus = ProposalStatus.Refused; - break; - case BackupProposalStatus.Repurchase: - proposalStatus = ProposalStatus.Repurchase; - break; - case BackupProposalStatus.PermanentlyFailed: - proposalStatus = ProposalStatus.PermanentlyFailed; - break; - } - if (backupProposal.contract_terms_raw) { - checkDbInvariant(!!backupProposal.merchant_sig); - const parsedContractTerms = codecForContractTerms().decode( - backupProposal.contract_terms_raw, - ); - const amount = Amounts.parseOrThrow(parsedContractTerms.amount); - const contractTermsHash = - cryptoComp.proposalIdToContractTermsHash[ - backupProposal.proposal_id - ]; - let maxWireFee: AmountJson; - if (parsedContractTerms.max_wire_fee) { - maxWireFee = Amounts.parseOrThrow( - parsedContractTerms.max_wire_fee, - ); - } else { - maxWireFee = Amounts.getZero(amount.currency); - } - download = { - contractData: { - amount, - contractTermsHash: contractTermsHash, - fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "", - merchantBaseUrl: parsedContractTerms.merchant_base_url, - merchantPub: parsedContractTerms.merchant_pub, - merchantSig: backupProposal.merchant_sig, - orderId: parsedContractTerms.order_id, - summary: parsedContractTerms.summary, - autoRefund: parsedContractTerms.auto_refund, - maxWireFee, - payDeadline: parsedContractTerms.pay_deadline, - refundDeadline: parsedContractTerms.refund_deadline, - wireFeeAmortization: - parsedContractTerms.wire_fee_amortization || 1, - allowedAuditors: parsedContractTerms.auditors.map((x) => ({ - auditorBaseUrl: x.url, - auditorPub: x.auditor_pub, - })), - allowedExchanges: parsedContractTerms.exchanges.map((x) => ({ - exchangeBaseUrl: x.url, - exchangePub: x.master_pub, - })), - timestamp: parsedContractTerms.timestamp, - wireMethod: parsedContractTerms.wire_method, - wireInfoHash: parsedContractTerms.h_wire, - maxDepositFee: Amounts.parseOrThrow( - parsedContractTerms.max_fee, - ), - merchant: parsedContractTerms.merchant, - products: parsedContractTerms.products, - summaryI18n: parsedContractTerms.summary_i18n, - deliveryDate: parsedContractTerms.delivery_date, - deliveryLocation: parsedContractTerms.delivery_location, - }, - contractTermsRaw: backupProposal.contract_terms_raw, - }; - } - await tx.proposals.put({ - claimToken: backupProposal.claim_token, - merchantBaseUrl: backupProposal.merchant_base_url, - timestamp: backupProposal.timestamp, - orderId: backupProposal.order_id, - noncePriv: backupProposal.nonce_priv, - noncePub: - cryptoComp.proposalNoncePrivToPub[backupProposal.nonce_priv], - proposalId: backupProposal.proposal_id, - repurchaseProposalId: backupProposal.repurchase_proposal_id, - download, - proposalStatus, - // FIXME! - downloadSessionId: undefined, - }); - } - } - for (const backupPurchase of backupBlob.purchases) { const ts = makeEventId( TombstoneTag.DeletePayment, @@ -678,6 +564,14 @@ export async function importBackup( const existingPurchase = await tx.purchases.get( backupPurchase.proposal_id, ); + let proposalStatus: ProposalStatus; + switch (backupPurchase.proposal_status) { + case BackupProposalStatus.Paid: + proposalStatus = ProposalStatus.Paid; + break; + default: + throw Error(); + } if (!existingPurchase) { const refunds: { [refundKey: string]: WalletRefundItem } = {}; for (const backupRefund of backupPurchase.refunds) { @@ -721,25 +615,6 @@ export async function importBackup( break; } } - let abortStatus: AbortStatus; - switch (backupPurchase.abort_status) { - case "abort-finished": - abortStatus = AbortStatus.AbortFinished; - break; - case "abort-refund": - abortStatus = AbortStatus.AbortRefund; - break; - case undefined: - abortStatus = AbortStatus.None; - break; - default: - logger.warn( - `got backup purchase abort_status ${j2s( - backupPurchase.abort_status, - )}`, - ); - throw Error("not reachable"); - } const parsedContractTerms = codecForContractTerms().decode( backupPurchase.contract_terms_raw, ); @@ -761,7 +636,7 @@ export async function importBackup( fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "", merchantBaseUrl: parsedContractTerms.merchant_base_url, merchantPub: parsedContractTerms.merchant_pub, - merchantSig: backupPurchase.merchant_sig, + merchantSig: backupPurchase.merchant_sig!, orderId: parsedContractTerms.order_id, summary: parsedContractTerms.summary, autoRefund: parsedContractTerms.auto_refund, @@ -790,33 +665,46 @@ export async function importBackup( }, contractTermsRaw: backupPurchase.contract_terms_raw, }; + + let payInfo: PurchasePayInfo | undefined = undefined; + if (backupPurchase.pay_info) { + payInfo = { + coinDepositPermissions: undefined, + payCoinSelection: await recoverPayCoinSelection( + tx, + download.contractData, + backupPurchase.pay_info, + ), + payCoinSelectionUid: backupPurchase.pay_info.pay_coins_uid, + totalPayCost: Amounts.parseOrThrow( + backupPurchase.pay_info.total_pay_cost, + ), + }; + } + await tx.purchases.put({ proposalId: backupPurchase.proposal_id, noncePriv: backupPurchase.nonce_priv, noncePub: cryptoComp.proposalNoncePrivToPub[backupPurchase.nonce_priv], autoRefundDeadline: TalerProtocolTimestamp.never(), - refundAwaiting: undefined, - timestampAccept: backupPurchase.timestamp_accept, + timestampAccept: backupPurchase.timestamp_accepted, timestampFirstSuccessfulPay: backupPurchase.timestamp_first_successful_pay, timestampLastRefundStatus: undefined, merchantPaySig: backupPurchase.merchant_pay_sig, lastSessionId: undefined, - abortStatus, download, - paymentSubmitPending: - !backupPurchase.timestamp_first_successful_pay, - refundQueryRequested: false, - payCoinSelection: await recoverPayCoinSelection( - tx, - download.contractData, - backupPurchase, - ), - coinDepositPermissions: undefined, - totalPayCost: Amounts.parseOrThrow(backupPurchase.total_pay_cost), refunds, - payCoinSelectionUid: backupPurchase.pay_coins_uid, + claimToken: backupPurchase.claim_token, + downloadSessionId: backupPurchase.download_session_id, + merchantBaseUrl: backupPurchase.merchant_base_url, + orderId: backupPurchase.order_id, + payInfo, + refundAmountAwaiting: undefined, + repurchaseProposalId: backupPurchase.repurchase_proposal_id, + status: proposalStatus, + timestamp: backupPurchase.timestamp_proposed, }); } } @@ -948,7 +836,6 @@ export async function importBackup( await tx.depositGroups.delete(rest[0]); } else if (type === TombstoneTag.DeletePayment) { await tx.purchases.delete(rest[0]); - await tx.proposals.delete(rest[0]); } else if (type === TombstoneTag.DeleteRefreshGroup) { await tx.refreshGroups.delete(rest[0]); } else if (type === TombstoneTag.DeleteRefund) { diff --git a/packages/taler-wallet-core/src/operations/backup/index.ts b/packages/taler-wallet-core/src/operations/backup/index.ts index fc84ce4ef..3d3ebf04a 100644 --- a/packages/taler-wallet-core/src/operations/backup/index.ts +++ b/packages/taler-wallet-core/src/operations/backup/index.ts @@ -96,7 +96,7 @@ import { checkPaymentByProposalId, confirmPay, preparePayForUri, -} from "../pay.js"; +} from "../pay-merchant.js"; import { exportBackup } from "./export.js"; import { BackupCryptoPrecomputedData, importBackup } from "./import.js"; import { getWalletBackupState, provideBackupState } from "./state.js"; @@ -193,15 +193,6 @@ async function computeBackupCryptoData( eddsaGetPublic(decodeCrock(backupWg.reserve_priv)), ); } - for (const prop of backupContent.proposals) { - const { h: contractTermsHash } = await cryptoApi.hashString({ - str: canonicalJson(prop.contract_terms_raw), - }); - const noncePub = encodeCrock(eddsaGetPublic(decodeCrock(prop.nonce_priv))); - cryptoData.proposalNoncePrivToPub[prop.nonce_priv] = noncePub; - cryptoData.proposalIdToContractTermsHash[prop.proposal_id] = - contractTermsHash; - } for (const purch of backupContent.purchases) { const { h: contractTermsHash } = await cryptoApi.hashString({ str: canonicalJson(purch.contract_terms_raw), diff --git a/packages/taler-wallet-core/src/operations/common.ts b/packages/taler-wallet-core/src/operations/common.ts index 6d54503a1..9f235c9b4 100644 --- a/packages/taler-wallet-core/src/operations/common.ts +++ b/packages/taler-wallet-core/src/operations/common.ts @@ -17,38 +17,272 @@ /** * Imports. */ -import { TalerErrorDetail, TalerErrorCode } from "@gnu-taler/taler-util"; -import { CryptoApiStoppedError } from "../crypto/workers/cryptoDispatcher.js"; -import { TalerError, getErrorDetailFromException } from "../errors.js"; +import { + AmountJson, + Amounts, + j2s, + Logger, + RefreshReason, + TalerErrorCode, + TalerErrorDetail, + TransactionType, +} from "@gnu-taler/taler-util"; +import { WalletStoresV1, CoinStatus, CoinRecord } from "../db.js"; +import { makeErrorDetail, TalerError } from "../errors.js"; +import { InternalWalletState } from "../internal-wallet-state.js"; +import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js"; +import { GetReadWriteAccess } from "../util/query.js"; +import { + OperationAttemptResult, + OperationAttemptResultType, + RetryInfo, +} from "../util/retries.js"; +import { createRefreshGroup } from "./refresh.js"; -/** - * Run an operation and call the onOpError callback - * when there was an exception or operation error that must be reported. - * The cause will be re-thrown to the caller. - */ -export async function guardOperationException( - op: () => Promise, - onOpError: (e: TalerErrorDetail) => Promise, -): Promise { +const logger = new Logger("operations/common.ts"); + +export interface CoinsSpendInfo { + coinPubs: string[]; + contributions: AmountJson[]; + refreshReason: RefreshReason; + /** + * Identifier for what the coin has been spent for. + */ + allocationId: string; +} + +export async function makeCoinAvailable( + ws: InternalWalletState, + tx: GetReadWriteAccess<{ + coins: typeof WalletStoresV1.coins; + coinAvailability: typeof WalletStoresV1.coinAvailability; + denominations: typeof WalletStoresV1.denominations; + }>, + coinRecord: CoinRecord, +): Promise { + checkLogicInvariant(coinRecord.status === CoinStatus.Fresh); + const existingCoin = await tx.coins.get(coinRecord.coinPub); + if (existingCoin) { + return; + } + const denom = await tx.denominations.get([ + coinRecord.exchangeBaseUrl, + coinRecord.denomPubHash, + ]); + checkDbInvariant(!!denom); + const ageRestriction = coinRecord.maxAge; + let car = await tx.coinAvailability.get([ + coinRecord.exchangeBaseUrl, + coinRecord.denomPubHash, + ageRestriction, + ]); + if (!car) { + car = { + maxAge: ageRestriction, + amountFrac: denom.amountFrac, + amountVal: denom.amountVal, + currency: denom.currency, + denomPubHash: denom.denomPubHash, + exchangeBaseUrl: denom.exchangeBaseUrl, + freshCoinCount: 0, + }; + } + car.freshCoinCount++; + await tx.coins.put(coinRecord); + await tx.coinAvailability.put(car); +} + +export async function spendCoins( + ws: InternalWalletState, + tx: GetReadWriteAccess<{ + coins: typeof WalletStoresV1.coins; + coinAvailability: typeof WalletStoresV1.coinAvailability; + refreshGroups: typeof WalletStoresV1.refreshGroups; + denominations: typeof WalletStoresV1.denominations; + }>, + csi: CoinsSpendInfo, +): Promise { + for (let i = 0; i < csi.coinPubs.length; i++) { + const coin = await tx.coins.get(csi.coinPubs[i]); + if (!coin) { + throw Error("coin allocated for payment doesn't exist anymore"); + } + const coinAvailability = await tx.coinAvailability.get([ + coin.exchangeBaseUrl, + coin.denomPubHash, + coin.maxAge, + ]); + checkDbInvariant(!!coinAvailability); + const contrib = csi.contributions[i]; + if (coin.status !== CoinStatus.Fresh) { + const alloc = coin.allocation; + if (!alloc) { + continue; + } + if (alloc.id !== csi.allocationId) { + // FIXME: assign error code + throw Error("conflicting coin allocation (id)"); + } + if (0 !== Amounts.cmp(alloc.amount, contrib)) { + // FIXME: assign error code + throw Error("conflicting coin allocation (contrib)"); + } + continue; + } + coin.status = CoinStatus.Dormant; + coin.allocation = { + id: csi.allocationId, + amount: Amounts.stringify(contrib), + }; + const remaining = Amounts.sub(coin.currentAmount, contrib); + if (remaining.saturated) { + throw Error("not enough remaining balance on coin for payment"); + } + coin.currentAmount = remaining.amount; + checkDbInvariant(!!coinAvailability); + if (coinAvailability.freshCoinCount === 0) { + throw Error( + `invalid coin count ${coinAvailability.freshCoinCount} in DB`, + ); + } + coinAvailability.freshCoinCount--; + await tx.coins.put(coin); + await tx.coinAvailability.put(coinAvailability); + } + const refreshCoinPubs = csi.coinPubs.map((x) => ({ + coinPub: x, + })); + await ws.refreshOps.createRefreshGroup( + ws, + tx, + refreshCoinPubs, + RefreshReason.PayMerchant, + ); +} + +export async function storeOperationError( + ws: InternalWalletState, + pendingTaskId: string, + e: TalerErrorDetail, +): Promise { + await ws.db + .mktx((x) => [x.operationRetries]) + .runReadWrite(async (tx) => { + let retryRecord = await tx.operationRetries.get(pendingTaskId); + if (!retryRecord) { + retryRecord = { + id: pendingTaskId, + lastError: e, + retryInfo: RetryInfo.reset(), + }; + } else { + retryRecord.lastError = e; + retryRecord.retryInfo = RetryInfo.increment(retryRecord.retryInfo); + } + await tx.operationRetries.put(retryRecord); + }); +} + +export async function storeOperationPending( + ws: InternalWalletState, + pendingTaskId: string, +): Promise { + await ws.db + .mktx((x) => [x.operationRetries]) + .runReadWrite(async (tx) => { + let retryRecord = await tx.operationRetries.get(pendingTaskId); + if (!retryRecord) { + retryRecord = { + id: pendingTaskId, + retryInfo: RetryInfo.reset(), + }; + } else { + delete retryRecord.lastError; + retryRecord.retryInfo = RetryInfo.increment(retryRecord.retryInfo); + } + await tx.operationRetries.put(retryRecord); + }); +} + +export async function runOperationWithErrorReporting( + ws: InternalWalletState, + opId: string, + f: () => Promise, +): Promise { + let maybeError: TalerErrorDetail | undefined; try { - return await op(); - } catch (e: any) { - if (e instanceof CryptoApiStoppedError) { - throw e; + const resp = await f(); + switch (resp.type) { + case OperationAttemptResultType.Error: + return await storeOperationError(ws, opId, resp.errorDetail); + case OperationAttemptResultType.Finished: + return await storeOperationFinished(ws, opId); + case OperationAttemptResultType.Pending: + return await storeOperationPending(ws, opId); + case OperationAttemptResultType.Longpoll: + break; } - if ( - e instanceof TalerError && - e.hasErrorCode(TalerErrorCode.WALLET_PENDING_OPERATION_FAILED) - ) { - throw e; + } catch (e) { + if (e instanceof TalerError) { + logger.warn("operation processed resulted in error"); + logger.warn(`error was: ${j2s(e.errorDetail)}`); + maybeError = e.errorDetail; + return await storeOperationError(ws, opId, maybeError!); + } else if (e instanceof Error) { + // This is a bug, as we expect pending operations to always + // do their own error handling and only throw WALLET_PENDING_OPERATION_FAILED + // or return something. + logger.error(`Uncaught exception: ${e.message}`); + logger.error(`Stack: ${e.stack}`); + maybeError = makeErrorDetail( + TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION, + { + stack: e.stack, + }, + `unexpected exception (message: ${e.message})`, + ); + return await storeOperationError(ws, opId, maybeError); + } else { + logger.error("Uncaught exception, value is not even an error."); + maybeError = makeErrorDetail( + TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION, + {}, + `unexpected exception (not even an error)`, + ); + return await storeOperationError(ws, opId, maybeError); } - const opErr = getErrorDetailFromException(e); - await onOpError(opErr); - throw TalerError.fromDetail( - TalerErrorCode.WALLET_PENDING_OPERATION_FAILED, - { - innerError: opErr, - }, - ); } } + +export async function storeOperationFinished( + ws: InternalWalletState, + pendingTaskId: string, +): Promise { + await ws.db + .mktx((x) => [x.operationRetries]) + .runReadWrite(async (tx) => { + await tx.operationRetries.delete(pendingTaskId); + }); +} + +export enum TombstoneTag { + DeleteWithdrawalGroup = "delete-withdrawal-group", + DeleteReserve = "delete-reserve", + DeletePayment = "delete-payment", + DeleteTip = "delete-tip", + DeleteRefreshGroup = "delete-refresh-group", + DeleteDepositGroup = "delete-deposit-group", + DeleteRefund = "delete-refund", + DeletePeerPullDebit = "delete-peer-pull-debit", + DeletePeerPushDebit = "delete-peer-push-debit", +} + +/** + * Create an event ID from the type and the primary key for the event. + */ +export function makeEventId( + type: TransactionType | TombstoneTag, + ...args: string[] +): string { + return type + ":" + args.map((x) => encodeURIComponent(x)).join(":"); +} diff --git a/packages/taler-wallet-core/src/operations/deposits.ts b/packages/taler-wallet-core/src/operations/deposits.ts index 625bc0828..1f7d05d29 100644 --- a/packages/taler-wallet-core/src/operations/deposits.ts +++ b/packages/taler-wallet-core/src/operations/deposits.ts @@ -53,16 +53,15 @@ import { import { InternalWalletState } from "../internal-wallet-state.js"; import { readSuccessResponseJsonOrThrow } from "../util/http.js"; import { OperationAttemptResult } from "../util/retries.js"; -import { spendCoins } from "../wallet.js"; +import { makeEventId, spendCoins } from "./common.js"; import { getExchangeDetails } from "./exchanges.js"; import { extractContractData, generateDepositPermissions, getTotalPaymentCost, selectPayCoinsNew, -} from "./pay.js"; +} from "./pay-merchant.js"; import { getTotalRefreshCost } from "./refresh.js"; -import { makeEventId } from "./transactions.js"; /** * Logger. diff --git a/packages/taler-wallet-core/src/operations/exchanges.ts b/packages/taler-wallet-core/src/operations/exchanges.ts index 1dd8660b5..9a6c72577 100644 --- a/packages/taler-wallet-core/src/operations/exchanges.ts +++ b/packages/taler-wallet-core/src/operations/exchanges.ts @@ -40,7 +40,6 @@ import { parsePaytoUri, Recoup, TalerErrorCode, - TalerErrorDetail, TalerProtocolDuration, TalerProtocolTimestamp, URL, @@ -71,11 +70,9 @@ import { import { OperationAttemptResult, OperationAttemptResultType, - RetryInfo, runOperationHandlerForResult, } from "../util/retries.js"; import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "../versions.js"; -import { guardOperationException } from "./common.js"; const logger = new Logger("exchanges.ts"); diff --git a/packages/taler-wallet-core/src/operations/merchants.ts b/packages/taler-wallet-core/src/operations/merchants.ts index 614478715..f5b3ca38c 100644 --- a/packages/taler-wallet-core/src/operations/merchants.ts +++ b/packages/taler-wallet-core/src/operations/merchants.ts @@ -25,7 +25,7 @@ import { LibtoolVersion, } from "@gnu-taler/taler-util"; import { InternalWalletState, MerchantInfo } from "../internal-wallet-state.js"; -import { readSuccessResponseJsonOrThrow } from "../index.js"; +import { readSuccessResponseJsonOrThrow } from "../util/http.js"; const logger = new Logger("taler-wallet-core:merchants.ts"); @@ -40,7 +40,7 @@ export async function getMerchantInfo( return existingInfo; } - const configUrl = new URL("config", canonBaseUrl); +const configUrl = new URL("config", canonBaseUrl); const resp = await ws.http.get(configUrl.href); const configResp = await readSuccessResponseJsonOrThrow( diff --git a/packages/taler-wallet-core/src/operations/pay-merchant.ts b/packages/taler-wallet-core/src/operations/pay-merchant.ts new file mode 100644 index 000000000..97901c71e --- /dev/null +++ b/packages/taler-wallet-core/src/operations/pay-merchant.ts @@ -0,0 +1,2758 @@ +/* + This file is part of GNU Taler + (C) 2019-2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +/** + * Implementation of the payment operation, including downloading and + * claiming of proposals. + * + * @author Florian Dold + */ + +/** + * Imports. + */ +import { GlobalIDB } from "@gnu-taler/idb-bridge"; +import { + AbortingCoin, + AbortRequest, + AbsoluteTime, + AgeRestriction, + AmountJson, + Amounts, + ApplyRefundResponse, + codecForAbortResponse, + codecForContractTerms, + codecForMerchantOrderRefundPickupResponse, + codecForMerchantOrderStatusPaid, + codecForMerchantPayResponse, + codecForProposal, + CoinDepositPermission, + CoinPublicKey, + ConfirmPayResult, + ConfirmPayResultType, + ContractTerms, + ContractTermsUtil, + DenominationInfo, + Duration, + encodeCrock, + ForcedCoinSel, + getRandomBytes, + HttpStatusCode, + j2s, + Logger, + MerchantCoinRefundFailureStatus, + MerchantCoinRefundStatus, + MerchantCoinRefundSuccessStatus, + NotificationType, + parsePaytoUri, + parsePayUri, + parseRefundUri, + PayCoinSelection, + PreparePayResult, + PreparePayResultType, + PrepareRefundResult, + RefreshReason, + strcmp, + TalerErrorCode, + TalerErrorDetail, + TalerProtocolTimestamp, + TransactionType, + URL, +} from "@gnu-taler/taler-util"; +import { EddsaKeypair } from "../crypto/cryptoImplementation.js"; +import { + AllowedAuditorInfo, + AllowedExchangeInfo, + BackupProviderStateTag, + CoinRecord, + CoinStatus, + DenominationRecord, + ProposalDownload, + ProposalStatus, + PurchaseRecord, + RefundReason, + RefundState, + WalletContractData, + WalletStoresV1, +} from "../db.js"; +import { + makeErrorDetail, + makePendingOperationFailedError, + TalerError, + TalerProtocolViolationError, +} from "../errors.js"; +import { GetReadWriteAccess } from "../index.browser.js"; +import { + EXCHANGE_COINS_LOCK, + InternalWalletState, +} from "../internal-wallet-state.js"; +import { PendingTaskType } from "../pending-types.js"; +import { assertUnreachable } from "../util/assertUnreachable.js"; +import { + CoinSelectionTally, + PreviousPayCoins, + tallyFees, +} from "../util/coinSelection.js"; +import { + getHttpResponseErrorDetails, + readSuccessResponseJsonOrErrorCode, + readSuccessResponseJsonOrThrow, + readTalerErrorResponse, + readUnexpectedResponseDetails, + throwUnexpectedRequestError, +} from "../util/http.js"; +import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js"; +import { + OperationAttemptResult, + OperationAttemptResultType, + RetryInfo, + RetryTags, + scheduleRetry, +} from "../util/retries.js"; +import { + spendCoins, + storeOperationPending, + storeOperationError, + makeEventId, +} from "./common.js"; +import { getExchangeDetails } from "./exchanges.js"; +import { createRefreshGroup, getTotalRefreshCost } from "./refresh.js"; + +/** + * Logger. + */ +const logger = new Logger("pay.ts"); + +/** + * Compute the total cost of a payment to the customer. + * + * This includes the amount taken by the merchant, fees (wire/deposit) contributed + * by the customer, refreshing fees, fees for withdraw-after-refresh and "trimmings" + * of coins that are too small to spend. + */ +export async function getTotalPaymentCost( + ws: InternalWalletState, + pcs: PayCoinSelection, +): Promise { + return ws.db + .mktx((x) => [x.coins, x.denominations]) + .runReadOnly(async (tx) => { + const costs: AmountJson[] = []; + for (let i = 0; i < pcs.coinPubs.length; i++) { + const coin = await tx.coins.get(pcs.coinPubs[i]); + if (!coin) { + throw Error("can't calculate payment cost, coin not found"); + } + const denom = await tx.denominations.get([ + coin.exchangeBaseUrl, + coin.denomPubHash, + ]); + if (!denom) { + throw Error( + "can't calculate payment cost, denomination for coin not found", + ); + } + const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl + .iter(coin.exchangeBaseUrl) + .filter((x) => + Amounts.isSameCurrency( + DenominationRecord.getValue(x), + pcs.coinContributions[i], + ), + ); + const amountLeft = Amounts.sub( + DenominationRecord.getValue(denom), + pcs.coinContributions[i], + ).amount; + const refreshCost = getTotalRefreshCost( + allDenoms, + DenominationRecord.toDenomInfo(denom), + amountLeft, + ); + costs.push(pcs.coinContributions[i]); + costs.push(refreshCost); + } + const zero = Amounts.getZero(pcs.paymentAmount.currency); + return Amounts.sum([zero, ...costs]).amount; + }); +} + +export interface CoinSelectionRequest { + amount: AmountJson; + + allowedAuditors: AllowedAuditorInfo[]; + allowedExchanges: AllowedExchangeInfo[]; + + /** + * Timestamp of the contract. + */ + timestamp: TalerProtocolTimestamp; + + wireMethod: string; + + wireFeeAmortization: number; + + maxWireFee: AmountJson; + + maxDepositFee: AmountJson; + + /** + * Minimum age requirement for the coin selection. + * + * When present, only select coins with either no age restriction + * or coins with an age commitment that matches the minimum age. + */ + minimumAge?: number; +} + +async function failProposalPermanently( + ws: InternalWalletState, + proposalId: string, + err: TalerErrorDetail, +): Promise { + await ws.db + .mktx((x) => [x.purchases]) + .runReadWrite(async (tx) => { + const p = await tx.purchases.get(proposalId); + if (!p) { + return; + } + p.status = ProposalStatus.ProposalDownloadFailed; + await tx.purchases.put(p); + }); +} + +function getProposalRequestTimeout(retryInfo?: RetryInfo): Duration { + return Duration.clamp({ + lower: Duration.fromSpec({ seconds: 1 }), + upper: Duration.fromSpec({ seconds: 60 }), + value: retryInfo ? RetryInfo.getDuration(retryInfo) : Duration.fromSpec({}), + }); +} + +function getPayRequestTimeout(purchase: PurchaseRecord): Duration { + return Duration.multiply( + { d_ms: 15000 }, + 1 + (purchase.payInfo?.payCoinSelection.coinPubs.length ?? 0) / 5, + ); +} + +/** + * Return the proposal download data for a purchase, throw if not available. + * + * (Async since in the future this will query the DB.) + */ +export async function expectProposalDownload( + p: PurchaseRecord, +): Promise { + if (!p.download) { + throw Error("expected proposal to be downloaded"); + } + return p.download; +} + +export function extractContractData( + parsedContractTerms: ContractTerms, + contractTermsHash: string, + merchantSig: string, +): WalletContractData { + const amount = Amounts.parseOrThrow(parsedContractTerms.amount); + let maxWireFee: AmountJson; + if (parsedContractTerms.max_wire_fee) { + maxWireFee = Amounts.parseOrThrow(parsedContractTerms.max_wire_fee); + } else { + maxWireFee = Amounts.getZero(amount.currency); + } + return { + amount, + contractTermsHash: contractTermsHash, + fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "", + merchantBaseUrl: parsedContractTerms.merchant_base_url, + merchantPub: parsedContractTerms.merchant_pub, + merchantSig, + orderId: parsedContractTerms.order_id, + summary: parsedContractTerms.summary, + autoRefund: parsedContractTerms.auto_refund, + maxWireFee, + payDeadline: parsedContractTerms.pay_deadline, + refundDeadline: parsedContractTerms.refund_deadline, + wireFeeAmortization: parsedContractTerms.wire_fee_amortization || 1, + allowedAuditors: parsedContractTerms.auditors.map((x) => ({ + auditorBaseUrl: x.url, + auditorPub: x.auditor_pub, + })), + allowedExchanges: parsedContractTerms.exchanges.map((x) => ({ + exchangeBaseUrl: x.url, + exchangePub: x.master_pub, + })), + timestamp: parsedContractTerms.timestamp, + wireMethod: parsedContractTerms.wire_method, + wireInfoHash: parsedContractTerms.h_wire, + maxDepositFee: Amounts.parseOrThrow(parsedContractTerms.max_fee), + merchant: parsedContractTerms.merchant, + products: parsedContractTerms.products, + summaryI18n: parsedContractTerms.summary_i18n, + minimumAge: parsedContractTerms.minimum_age, + deliveryDate: parsedContractTerms.delivery_date, + deliveryLocation: parsedContractTerms.delivery_location, + }; +} + +export async function processDownloadProposal( + ws: InternalWalletState, + proposalId: string, + options: object = {}, +): Promise { + const proposal = await ws.db + .mktx((x) => [x.purchases]) + .runReadOnly(async (tx) => { + return await tx.purchases.get(proposalId); + }); + + if (!proposal) { + return { + type: OperationAttemptResultType.Finished, + result: undefined, + }; + } + + if (proposal.status != ProposalStatus.DownloadingProposal) { + return { + type: OperationAttemptResultType.Finished, + result: undefined, + }; + } + + const orderClaimUrl = new URL( + `orders/${proposal.orderId}/claim`, + proposal.merchantBaseUrl, + ).href; + logger.trace("downloading contract from '" + orderClaimUrl + "'"); + + const requestBody: { + nonce: string; + token?: string; + } = { + nonce: proposal.noncePub, + }; + if (proposal.claimToken) { + requestBody.token = proposal.claimToken; + } + + const opId = RetryTags.forPay(proposal); + const retryRecord = await ws.db + .mktx((x) => [x.operationRetries]) + .runReadOnly(async (tx) => { + return tx.operationRetries.get(opId); + }); + + // FIXME: Do this in the background using the new return value + const httpResponse = await ws.http.postJson(orderClaimUrl, requestBody, { + timeout: getProposalRequestTimeout(retryRecord?.retryInfo), + }); + const r = await readSuccessResponseJsonOrErrorCode( + httpResponse, + codecForProposal(), + ); + if (r.isError) { + switch (r.talerErrorResponse.code) { + case TalerErrorCode.MERCHANT_POST_ORDERS_ID_CLAIM_ALREADY_CLAIMED: + throw TalerError.fromDetail( + TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED, + { + orderId: proposal.orderId, + claimUrl: orderClaimUrl, + }, + "order already claimed (likely by other wallet)", + ); + default: + throwUnexpectedRequestError(httpResponse, r.talerErrorResponse); + } + } + const proposalResp = r.response; + + // The proposalResp contains the contract terms as raw JSON, + // as the coded to parse them doesn't necessarily round-trip. + // We need this raw JSON to compute the contract terms hash. + + // FIXME: Do better error handling, check if the + // contract terms have all their forgettable information still + // present. The wallet should never accept contract terms + // with missing information from the merchant. + + const isWellFormed = ContractTermsUtil.validateForgettable( + proposalResp.contract_terms, + ); + + if (!isWellFormed) { + logger.trace( + `malformed contract terms: ${j2s(proposalResp.contract_terms)}`, + ); + const err = makeErrorDetail( + TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED, + {}, + "validation for well-formedness failed", + ); + await failProposalPermanently(ws, proposalId, err); + throw makePendingOperationFailedError( + err, + TransactionType.Payment, + proposalId, + ); + } + + const contractTermsHash = ContractTermsUtil.hashContractTerms( + proposalResp.contract_terms, + ); + + logger.info(`Contract terms hash: ${contractTermsHash}`); + + let parsedContractTerms: ContractTerms; + + try { + parsedContractTerms = codecForContractTerms().decode( + proposalResp.contract_terms, + ); + } catch (e) { + const err = makeErrorDetail( + TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED, + {}, + `schema validation failed: ${e}`, + ); + await failProposalPermanently(ws, proposalId, err); + throw makePendingOperationFailedError( + err, + TransactionType.Payment, + proposalId, + ); + } + + const sigValid = await ws.cryptoApi.isValidContractTermsSignature({ + contractTermsHash, + merchantPub: parsedContractTerms.merchant_pub, + sig: proposalResp.sig, + }); + + if (!sigValid) { + const err = makeErrorDetail( + TalerErrorCode.WALLET_CONTRACT_TERMS_SIGNATURE_INVALID, + { + merchantPub: parsedContractTerms.merchant_pub, + orderId: parsedContractTerms.order_id, + }, + "merchant's signature on contract terms is invalid", + ); + await failProposalPermanently(ws, proposalId, err); + throw makePendingOperationFailedError( + err, + TransactionType.Payment, + proposalId, + ); + } + + const fulfillmentUrl = parsedContractTerms.fulfillment_url; + + const baseUrlForDownload = proposal.merchantBaseUrl; + const baseUrlFromContractTerms = parsedContractTerms.merchant_base_url; + + if (baseUrlForDownload !== baseUrlFromContractTerms) { + const err = makeErrorDetail( + TalerErrorCode.WALLET_CONTRACT_TERMS_BASE_URL_MISMATCH, + { + baseUrlForDownload, + baseUrlFromContractTerms, + }, + "merchant base URL mismatch", + ); + await failProposalPermanently(ws, proposalId, err); + throw makePendingOperationFailedError( + err, + TransactionType.Payment, + proposalId, + ); + } + + const contractData = extractContractData( + parsedContractTerms, + contractTermsHash, + proposalResp.sig, + ); + + logger.trace(`extracted contract data: ${j2s(contractData)}`); + + await ws.db + .mktx((x) => [x.purchases]) + .runReadWrite(async (tx) => { + const p = await tx.purchases.get(proposalId); + if (!p) { + return; + } + if (p.status !== ProposalStatus.DownloadingProposal) { + return; + } + p.download = { + contractData, + contractTermsRaw: proposalResp.contract_terms, + }; + if ( + fulfillmentUrl && + (fulfillmentUrl.startsWith("http://") || + fulfillmentUrl.startsWith("https://")) + ) { + const differentPurchase = + await tx.purchases.indexes.byFulfillmentUrl.get(fulfillmentUrl); + if (differentPurchase) { + logger.warn("repurchase detected"); + p.status = ProposalStatus.RepurchaseDetected; + p.repurchaseProposalId = differentPurchase.proposalId; + await tx.purchases.put(p); + return; + } + } + p.status = ProposalStatus.Proposed; + await tx.purchases.put(p); + }); + + ws.notify({ + type: NotificationType.ProposalDownloaded, + proposalId: proposal.proposalId, + }); + + return { + type: OperationAttemptResultType.Finished, + result: undefined, + }; +} + +/** + * Download a proposal and store it in the database. + * Returns an id for it to retrieve it later. + * + * @param sessionId Current session ID, if the proposal is being + * downloaded in the context of a session ID. + */ +async function startDownloadProposal( + ws: InternalWalletState, + merchantBaseUrl: string, + orderId: string, + sessionId: string | undefined, + claimToken: string | undefined, + noncePriv: string | undefined, +): Promise { + const oldProposal = await ws.db + .mktx((x) => [x.purchases]) + .runReadOnly(async (tx) => { + return tx.purchases.indexes.byUrlAndOrderId.get([ + merchantBaseUrl, + orderId, + ]); + }); + + /* If we have already claimed this proposal with the same sessionId + * nonce and claim token, reuse it. */ + if ( + oldProposal && + oldProposal.downloadSessionId === sessionId && + (!noncePriv || oldProposal.noncePriv === noncePriv) && + oldProposal.claimToken === claimToken + ) { + await processDownloadProposal(ws, oldProposal.proposalId); + return oldProposal.proposalId; + } + + let noncePair: EddsaKeypair; + if (noncePriv) { + noncePair = { + priv: noncePriv, + pub: (await ws.cryptoApi.eddsaGetPublic({ priv: noncePriv })).pub, + }; + } else { + noncePair = await ws.cryptoApi.createEddsaKeypair({}); + } + + const { priv, pub } = noncePair; + const proposalId = encodeCrock(getRandomBytes(32)); + + const proposalRecord: PurchaseRecord = { + download: undefined, + noncePriv: priv, + noncePub: pub, + claimToken, + timestamp: AbsoluteTime.toTimestamp(AbsoluteTime.now()), + merchantBaseUrl, + orderId, + proposalId: proposalId, + status: ProposalStatus.DownloadingProposal, + repurchaseProposalId: undefined, + downloadSessionId: sessionId, + autoRefundDeadline: undefined, + lastSessionId: undefined, + merchantPaySig: undefined, + payInfo: undefined, + refundAmountAwaiting: undefined, + refunds: {}, + timestampAccept: undefined, + timestampFirstSuccessfulPay: undefined, + timestampLastRefundStatus: undefined, + pendingRemovedCoinPubs: undefined, + }; + + await ws.db + .mktx((x) => [x.purchases]) + .runReadWrite(async (tx) => { + const existingRecord = await tx.purchases.indexes.byUrlAndOrderId.get([ + merchantBaseUrl, + orderId, + ]); + if (existingRecord) { + // Created concurrently + return; + } + await tx.purchases.put(proposalRecord); + }); + + await processDownloadProposal(ws, proposalId); + return proposalId; +} + +async function storeFirstPaySuccess( + ws: InternalWalletState, + proposalId: string, + sessionId: string | undefined, + paySig: string, +): Promise { + const now = AbsoluteTime.toTimestamp(AbsoluteTime.now()); + await ws.db + .mktx((x) => [x.purchases]) + .runReadWrite(async (tx) => { + const purchase = await tx.purchases.get(proposalId); + + if (!purchase) { + logger.warn("purchase does not exist anymore"); + return; + } + const isFirst = purchase.timestampFirstSuccessfulPay === undefined; + if (!isFirst) { + logger.warn("payment success already stored"); + return; + } + if (purchase.status === ProposalStatus.Paying) { + purchase.status = ProposalStatus.Paid; + } + purchase.timestampFirstSuccessfulPay = now; + purchase.lastSessionId = sessionId; + purchase.merchantPaySig = paySig; + const protoAr = purchase.download!.contractData.autoRefund; + if (protoAr) { + const ar = Duration.fromTalerProtocolDuration(protoAr); + logger.info("auto_refund present"); + purchase.status = ProposalStatus.QueryingAutoRefund; + purchase.autoRefundDeadline = AbsoluteTime.toTimestamp( + AbsoluteTime.addDuration(AbsoluteTime.now(), ar), + ); + } + await tx.purchases.put(purchase); + }); +} + +async function storePayReplaySuccess( + ws: InternalWalletState, + proposalId: string, + sessionId: string | undefined, +): Promise { + await ws.db + .mktx((x) => [x.purchases]) + .runReadWrite(async (tx) => { + const purchase = await tx.purchases.get(proposalId); + + if (!purchase) { + logger.warn("purchase does not exist anymore"); + return; + } + const isFirst = purchase.timestampFirstSuccessfulPay === undefined; + if (isFirst) { + throw Error("invalid payment state"); + } + if (purchase.status === ProposalStatus.Paying) { + purchase.status = ProposalStatus.Paid; + } + purchase.lastSessionId = sessionId; + await tx.purchases.put(purchase); + }); +} + +/** + * Handle a 409 Conflict response from the merchant. + * + * We do this by going through the coin history provided by the exchange and + * (1) verifying the signatures from the exchange + * (2) adjusting the remaining coin value and refreshing it + * (3) re-do coin selection with the bad coin removed + */ +async function handleInsufficientFunds( + ws: InternalWalletState, + proposalId: string, + err: TalerErrorDetail, +): Promise { + logger.trace("handling insufficient funds, trying to re-select coins"); + + const proposal = await ws.db + .mktx((x) => [x.purchases]) + .runReadOnly(async (tx) => { + return tx.purchases.get(proposalId); + }); + if (!proposal) { + return; + } + + logger.trace(`got error details: ${j2s(err)}`); + + const exchangeReply = (err as any).exchange_reply; + if ( + exchangeReply.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS + ) { + // FIXME: set as failed + if (logger.shouldLogTrace()) { + logger.trace("got exchange error reply (see below)"); + logger.trace(j2s(exchangeReply)); + } + throw Error(`unable to handle /pay error response (${exchangeReply.code})`); + } + + const brokenCoinPub = (exchangeReply as any).coin_pub; + logger.trace(`excluded broken coin pub=${brokenCoinPub}`); + + if (!brokenCoinPub) { + throw new TalerProtocolViolationError(); + } + + const { contractData } = proposal.download!; + + const prevPayCoins: PreviousPayCoins = []; + + const payInfo = proposal.payInfo; + if (!payInfo) { + return; + } + + const payCoinSelection = payInfo.payCoinSelection; + + await ws.db + .mktx((x) => [x.coins, x.denominations]) + .runReadOnly(async (tx) => { + for (let i = 0; i < payCoinSelection.coinPubs.length; i++) { + const coinPub = payCoinSelection.coinPubs[i]; + if (coinPub === brokenCoinPub) { + continue; + } + const contrib = payCoinSelection.coinContributions[i]; + const coin = await tx.coins.get(coinPub); + if (!coin) { + continue; + } + const denom = await tx.denominations.get([ + coin.exchangeBaseUrl, + coin.denomPubHash, + ]); + if (!denom) { + continue; + } + prevPayCoins.push({ + coinPub, + contribution: contrib, + exchangeBaseUrl: coin.exchangeBaseUrl, + feeDeposit: denom.fees.feeDeposit, + }); + } + }); + + const res = await selectPayCoinsNew(ws, { + auditors: contractData.allowedAuditors, + exchanges: contractData.allowedExchanges, + wireMethod: contractData.wireMethod, + contractTermsAmount: contractData.amount, + depositFeeLimit: contractData.maxDepositFee, + wireFeeAmortization: contractData.wireFeeAmortization ?? 1, + wireFeeLimit: contractData.maxWireFee, + prevPayCoins, + requiredMinimumAge: contractData.minimumAge, + }); + + if (!res) { + logger.trace("insufficient funds for coin re-selection"); + return; + } + + logger.trace("re-selected coins"); + + await ws.db + .mktx((x) => [ + x.purchases, + x.coins, + x.coinAvailability, + x.denominations, + x.refreshGroups, + ]) + .runReadWrite(async (tx) => { + const p = await tx.purchases.get(proposalId); + if (!p) { + return; + } + const payInfo = p.payInfo; + if (!payInfo) { + return; + } + payInfo.payCoinSelection = res; + payInfo.payCoinSelection = res; + payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(32)); + payInfo.coinDepositPermissions = undefined; + await tx.purchases.put(p); + await spendCoins(ws, tx, { + allocationId: `proposal:${p.proposalId}`, + coinPubs: payInfo.payCoinSelection.coinPubs, + contributions: payInfo.payCoinSelection.coinContributions, + refreshReason: RefreshReason.PayMerchant, + }); + }); +} + +async function unblockBackup( + ws: InternalWalletState, + proposalId: string, +): Promise { + await ws.db + .mktx((x) => [x.backupProviders]) + .runReadWrite(async (tx) => { + await tx.backupProviders.indexes.byPaymentProposalId + .iter(proposalId) + .forEachAsync(async (bp) => { + if (bp.state.tag === BackupProviderStateTag.Retrying) { + bp.state = { + tag: BackupProviderStateTag.Ready, + nextBackupTimestamp: TalerProtocolTimestamp.now(), + }; + } + }); + }); +} + +export interface SelectPayCoinRequestNg { + exchanges: AllowedExchangeInfo[]; + auditors: AllowedAuditorInfo[]; + wireMethod: string; + contractTermsAmount: AmountJson; + depositFeeLimit: AmountJson; + wireFeeLimit: AmountJson; + wireFeeAmortization: number; + prevPayCoins?: PreviousPayCoins; + requiredMinimumAge?: number; + forcedSelection?: ForcedCoinSel; +} + +export type AvailableDenom = DenominationInfo & { + maxAge: number; + numAvailable: number; +}; + +export async function selectCandidates( + ws: InternalWalletState, + req: SelectPayCoinRequestNg, +): Promise<[AvailableDenom[], Record]> { + return await ws.db + .mktx((x) => [ + x.exchanges, + x.exchangeDetails, + x.denominations, + x.coinAvailability, + ]) + .runReadOnly(async (tx) => { + const denoms: AvailableDenom[] = []; + const exchanges = await tx.exchanges.iter().toArray(); + const wfPerExchange: Record = {}; + for (const exchange of exchanges) { + const exchangeDetails = await getExchangeDetails(tx, exchange.baseUrl); + if (exchangeDetails?.currency !== req.contractTermsAmount.currency) { + continue; + } + let wireMethodSupported = false; + for (const acc of exchangeDetails.wireInfo.accounts) { + const pp = parsePaytoUri(acc.payto_uri); + checkLogicInvariant(!!pp); + if (pp.targetType === req.wireMethod) { + wireMethodSupported = true; + break; + } + } + if (!wireMethodSupported) { + break; + } + exchangeDetails.wireInfo.accounts; + let accepted = false; + for (const allowedExchange of req.exchanges) { + if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) { + accepted = true; + break; + } + } + for (const allowedAuditor of req.auditors) { + for (const providedAuditor of exchangeDetails.auditors) { + if (allowedAuditor.auditorPub === providedAuditor.auditor_pub) { + accepted = true; + break; + } + } + } + if (!accepted) { + continue; + } + let ageLower = 0; + let ageUpper = AgeRestriction.AGE_UNRESTRICTED; + if (req.requiredMinimumAge) { + ageLower = req.requiredMinimumAge; + } + const myExchangeDenoms = + await tx.coinAvailability.indexes.byExchangeAgeAvailability.getAll( + GlobalIDB.KeyRange.bound( + [exchangeDetails.exchangeBaseUrl, ageLower, 1], + [ + exchangeDetails.exchangeBaseUrl, + ageUpper, + Number.MAX_SAFE_INTEGER, + ], + ), + ); + // FIXME: Check that the individual denomination is audited! + // FIXME: Should we exclude denominations that are + // not spendable anymore? + for (const denomAvail of myExchangeDenoms) { + const denom = await tx.denominations.get([ + denomAvail.exchangeBaseUrl, + denomAvail.denomPubHash, + ]); + checkDbInvariant(!!denom); + if (denom.isRevoked || !denom.isOffered) { + continue; + } + denoms.push({ + ...DenominationRecord.toDenomInfo(denom), + numAvailable: denomAvail.freshCoinCount ?? 0, + maxAge: denomAvail.maxAge, + }); + } + } + // Sort by available amount (descending), deposit fee (ascending) and + // denomPub (ascending) if deposit fee is the same + // (to guarantee deterministic results) + denoms.sort( + (o1, o2) => + -Amounts.cmp(o1.value, o2.value) || + Amounts.cmp(o1.feeDeposit, o2.feeDeposit) || + strcmp(o1.denomPubHash, o2.denomPubHash), + ); + return [denoms, wfPerExchange]; + }); +} + +function makeAvailabilityKey( + exchangeBaseUrl: string, + denomPubHash: string, + maxAge: number, +): string { + return `${denomPubHash};${maxAge};${exchangeBaseUrl}`; +} + +/** + * Selection result. + */ +interface SelResult { + /** + * Map from an availability key + * to an array of contributions. + */ + [avKey: string]: { + exchangeBaseUrl: string; + denomPubHash: string; + maxAge: number; + contributions: AmountJson[]; + }; +} + +export function selectGreedy( + req: SelectPayCoinRequestNg, + candidateDenoms: AvailableDenom[], + wireFeesPerExchange: Record, + tally: CoinSelectionTally, +): SelResult | undefined { + const { wireFeeAmortization } = req; + const selectedDenom: SelResult = {}; + for (const aci of candidateDenoms) { + const contributions: AmountJson[] = []; + for (let i = 0; i < aci.numAvailable; i++) { + // Don't use this coin if depositing it is more expensive than + // the amount it would give the merchant. + if (Amounts.cmp(aci.feeDeposit, aci.value) > 0) { + continue; + } + + if (Amounts.isZero(tally.amountPayRemaining)) { + // We have spent enough! + break; + } + + tally = tallyFees( + tally, + wireFeesPerExchange, + wireFeeAmortization, + aci.exchangeBaseUrl, + aci.feeDeposit, + ); + + let coinSpend = Amounts.max( + Amounts.min(tally.amountPayRemaining, aci.value), + aci.feeDeposit, + ); + + tally.amountPayRemaining = Amounts.sub( + tally.amountPayRemaining, + coinSpend, + ).amount; + contributions.push(coinSpend); + } + + if (contributions.length) { + const avKey = makeAvailabilityKey( + aci.exchangeBaseUrl, + aci.denomPubHash, + aci.maxAge, + ); + let sd = selectedDenom[avKey]; + if (!sd) { + sd = { + contributions: [], + denomPubHash: aci.denomPubHash, + exchangeBaseUrl: aci.exchangeBaseUrl, + maxAge: aci.maxAge, + }; + } + sd.contributions.push(...contributions); + selectedDenom[avKey] = sd; + } + + if (Amounts.isZero(tally.amountPayRemaining)) { + return selectedDenom; + } + } + return undefined; +} + +export function selectForced( + req: SelectPayCoinRequestNg, + candidateDenoms: AvailableDenom[], +): SelResult | undefined { + const selectedDenom: SelResult = {}; + + const forcedSelection = req.forcedSelection; + checkLogicInvariant(!!forcedSelection); + + for (const forcedCoin of forcedSelection.coins) { + let found = false; + for (const aci of candidateDenoms) { + if (aci.numAvailable <= 0) { + continue; + } + if (Amounts.cmp(aci.value, forcedCoin.value) === 0) { + aci.numAvailable--; + const avKey = makeAvailabilityKey( + aci.exchangeBaseUrl, + aci.denomPubHash, + aci.maxAge, + ); + let sd = selectedDenom[avKey]; + if (!sd) { + sd = { + contributions: [], + denomPubHash: aci.denomPubHash, + exchangeBaseUrl: aci.exchangeBaseUrl, + maxAge: aci.maxAge, + }; + } + sd.contributions.push(Amounts.parseOrThrow(forcedCoin.value)); + selectedDenom[avKey] = sd; + found = true; + break; + } + } + if (!found) { + throw Error("can't find coin for forced coin selection"); + } + } + + return selectedDenom; +} + +/** + * Given a list of candidate coins, select coins to spend under the merchant's + * constraints. + * + * The prevPayCoins can be specified to "repair" a coin selection + * by adding additional coins, after a broken (e.g. double-spent) coin + * has been removed from the selection. + * + * This function is only exported for the sake of unit tests. + */ +export async function selectPayCoinsNew( + ws: InternalWalletState, + req: SelectPayCoinRequestNg, +): Promise { + const { + contractTermsAmount, + depositFeeLimit, + wireFeeLimit, + wireFeeAmortization, + } = req; + + const [candidateDenoms, wireFeesPerExchange] = await selectCandidates( + ws, + req, + ); + + // logger.trace(`candidate denoms: ${j2s(candidateDenoms)}`); + + const coinPubs: string[] = []; + const coinContributions: AmountJson[] = []; + const currency = contractTermsAmount.currency; + + let tally: CoinSelectionTally = { + amountPayRemaining: contractTermsAmount, + amountWireFeeLimitRemaining: wireFeeLimit, + amountDepositFeeLimitRemaining: depositFeeLimit, + customerDepositFees: Amounts.getZero(currency), + customerWireFees: Amounts.getZero(currency), + wireFeeCoveredForExchange: new Set(), + }; + + const prevPayCoins = req.prevPayCoins ?? []; + + // Look at existing pay coin selection and tally up + for (const prev of prevPayCoins) { + tally = tallyFees( + tally, + wireFeesPerExchange, + wireFeeAmortization, + prev.exchangeBaseUrl, + prev.feeDeposit, + ); + tally.amountPayRemaining = Amounts.sub( + tally.amountPayRemaining, + prev.contribution, + ).amount; + + coinPubs.push(prev.coinPub); + coinContributions.push(prev.contribution); + } + + let selectedDenom: SelResult | undefined; + if (req.forcedSelection) { + selectedDenom = selectForced(req, candidateDenoms); + } else { + // FIXME: Here, we should select coins in a smarter way. + // Instead of always spending the next-largest coin, + // we should try to find the smallest coin that covers the + // amount. + selectedDenom = selectGreedy( + req, + candidateDenoms, + wireFeesPerExchange, + tally, + ); + } + + if (!selectedDenom) { + return undefined; + } + + const finalSel = selectedDenom; + + logger.trace(`coin selection request ${j2s(req)}`); + logger.trace(`selected coins (via denoms) for payment: ${j2s(finalSel)}`); + + await ws.db + .mktx((x) => [x.coins, x.denominations]) + .runReadOnly(async (tx) => { + for (const dph of Object.keys(finalSel)) { + const selInfo = finalSel[dph]; + const numRequested = selInfo.contributions.length; + const query = [ + selInfo.exchangeBaseUrl, + selInfo.denomPubHash, + selInfo.maxAge, + CoinStatus.Fresh, + ]; + logger.info(`query: ${j2s(query)}`); + const coins = + await tx.coins.indexes.byExchangeDenomPubHashAndAgeAndStatus.getAll( + query, + numRequested, + ); + if (coins.length != numRequested) { + throw Error( + `coin selection failed (not available anymore, got only ${coins.length}/${numRequested})`, + ); + } + coinPubs.push(...coins.map((x) => x.coinPub)); + coinContributions.push(...selInfo.contributions); + } + }); + + return { + paymentAmount: contractTermsAmount, + coinContributions, + coinPubs, + customerDepositFees: tally.customerDepositFees, + customerWireFees: tally.customerWireFees, + }; +} + +export async function checkPaymentByProposalId( + ws: InternalWalletState, + proposalId: string, + sessionId?: string, +): Promise { + let proposal = await ws.db + .mktx((x) => [x.purchases]) + .runReadOnly(async (tx) => { + return tx.purchases.get(proposalId); + }); + if (!proposal) { + throw Error(`could not get proposal ${proposalId}`); + } + if (proposal.status === ProposalStatus.RepurchaseDetected) { + const existingProposalId = proposal.repurchaseProposalId; + if (!existingProposalId) { + throw Error("invalid proposal state"); + } + logger.trace("using existing purchase for same product"); + proposal = await ws.db + .mktx((x) => [x.purchases]) + .runReadOnly(async (tx) => { + return tx.purchases.get(existingProposalId); + }); + if (!proposal) { + throw Error("existing proposal is in wrong state"); + } + } + const d = proposal.download; + if (!d) { + logger.error("bad proposal", proposal); + throw Error("proposal is in invalid state"); + } + const contractData = d.contractData; + const merchantSig = d.contractData.merchantSig; + if (!merchantSig) { + throw Error("BUG: proposal is in invalid state"); + } + + proposalId = proposal.proposalId; + + // First check if we already paid for it. + const purchase = await ws.db + .mktx((x) => [x.purchases]) + .runReadOnly(async (tx) => { + return tx.purchases.get(proposalId); + }); + + if (!purchase || purchase.status === ProposalStatus.Proposed) { + // If not already paid, check if we could pay for it. + const res = await selectPayCoinsNew(ws, { + auditors: contractData.allowedAuditors, + exchanges: contractData.allowedExchanges, + contractTermsAmount: contractData.amount, + depositFeeLimit: contractData.maxDepositFee, + wireFeeAmortization: contractData.wireFeeAmortization ?? 1, + wireFeeLimit: contractData.maxWireFee, + prevPayCoins: [], + requiredMinimumAge: contractData.minimumAge, + wireMethod: contractData.wireMethod, + }); + + if (!res) { + logger.info("not allowing payment, insufficient coins"); + return { + status: PreparePayResultType.InsufficientBalance, + contractTerms: d.contractTermsRaw, + proposalId: proposal.proposalId, + noncePriv: proposal.noncePriv, + amountRaw: Amounts.stringify(d.contractData.amount), + }; + } + + const totalCost = await getTotalPaymentCost(ws, res); + logger.trace("costInfo", totalCost); + logger.trace("coinsForPayment", res); + + return { + status: PreparePayResultType.PaymentPossible, + contractTerms: d.contractTermsRaw, + proposalId: proposal.proposalId, + noncePriv: proposal.noncePriv, + amountEffective: Amounts.stringify(totalCost), + amountRaw: Amounts.stringify(res.paymentAmount), + contractTermsHash: d.contractData.contractTermsHash, + }; + } + + if ( + purchase.status === ProposalStatus.Paid && + purchase.lastSessionId !== sessionId + ) { + logger.trace( + "automatically re-submitting payment with different session ID", + ); + logger.trace(`last: ${purchase.lastSessionId}, current: ${sessionId}`); + await ws.db + .mktx((x) => [x.purchases]) + .runReadWrite(async (tx) => { + const p = await tx.purchases.get(proposalId); + if (!p) { + return; + } + p.lastSessionId = sessionId; + p.status = ProposalStatus.PayingReplay; + await tx.purchases.put(p); + }); + const r = await processPurchasePay(ws, proposalId, { forceNow: true }); + if (r.type !== OperationAttemptResultType.Finished) { + // FIXME: This does not surface the original error + throw Error("submitting pay failed"); + } + const download = await expectProposalDownload(purchase); + return { + status: PreparePayResultType.AlreadyConfirmed, + contractTerms: download.contractTermsRaw, + contractTermsHash: download.contractData.contractTermsHash, + paid: true, + amountRaw: Amounts.stringify(download.contractData.amount), + amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!), + proposalId, + }; + } else if (!purchase.timestampFirstSuccessfulPay) { + const download = await expectProposalDownload(purchase); + return { + status: PreparePayResultType.AlreadyConfirmed, + contractTerms: download.contractTermsRaw, + contractTermsHash: download.contractData.contractTermsHash, + paid: false, + amountRaw: Amounts.stringify(download.contractData.amount), + amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!), + proposalId, + }; + } else { + const paid = + purchase.status === ProposalStatus.Paid || + purchase.status === ProposalStatus.QueryingRefund || + purchase.status === ProposalStatus.QueryingAutoRefund; + const download = await expectProposalDownload(purchase); + return { + status: PreparePayResultType.AlreadyConfirmed, + contractTerms: download.contractTermsRaw, + contractTermsHash: download.contractData.contractTermsHash, + paid, + amountRaw: Amounts.stringify(download.contractData.amount), + amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!), + ...(paid ? { nextUrl: download.contractData.orderId } : {}), + proposalId, + }; + } +} + +export async function getContractTermsDetails( + ws: InternalWalletState, + proposalId: string, +): Promise { + const proposal = await ws.db + .mktx((x) => [x.purchases]) + .runReadOnly(async (tx) => { + return tx.purchases.get(proposalId); + }); + + if (!proposal) { + throw Error(`proposal with id ${proposalId} not found`); + } + + if (!proposal.download || !proposal.download.contractData) { + throw Error("proposal is in invalid state"); + } + + return proposal.download.contractData; +} + +/** + * Check if a payment for the given taler://pay/ URI is possible. + * + * If the payment is possible, the signature are already generated but not + * yet send to the merchant. + */ +export async function preparePayForUri( + ws: InternalWalletState, + talerPayUri: string, +): Promise { + const uriResult = parsePayUri(talerPayUri); + + if (!uriResult) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_INVALID_TALER_PAY_URI, + { + talerPayUri, + }, + `invalid taler://pay URI (${talerPayUri})`, + ); + } + + let proposalId = await startDownloadProposal( + ws, + uriResult.merchantBaseUrl, + uriResult.orderId, + uriResult.sessionId, + uriResult.claimToken, + uriResult.noncePriv, + ); + + return checkPaymentByProposalId(ws, proposalId, uriResult.sessionId); +} + +/** + * Generate deposit permissions for a purchase. + * + * Accesses the database and the crypto worker. + */ +export async function generateDepositPermissions( + ws: InternalWalletState, + payCoinSel: PayCoinSelection, + contractData: WalletContractData, +): Promise { + const depositPermissions: CoinDepositPermission[] = []; + const coinWithDenom: Array<{ + coin: CoinRecord; + denom: DenominationRecord; + }> = []; + await ws.db + .mktx((x) => [x.coins, x.denominations]) + .runReadOnly(async (tx) => { + for (let i = 0; i < payCoinSel.coinPubs.length; i++) { + const coin = await tx.coins.get(payCoinSel.coinPubs[i]); + if (!coin) { + throw Error("can't pay, allocated coin not found anymore"); + } + const denom = await tx.denominations.get([ + coin.exchangeBaseUrl, + coin.denomPubHash, + ]); + if (!denom) { + throw Error( + "can't pay, denomination of allocated coin not found anymore", + ); + } + coinWithDenom.push({ coin, denom }); + } + }); + + for (let i = 0; i < payCoinSel.coinPubs.length; i++) { + const { coin, denom } = coinWithDenom[i]; + let wireInfoHash: string; + wireInfoHash = contractData.wireInfoHash; + logger.trace( + `signing deposit permission for coin with ageRestriction=${j2s( + coin.ageCommitmentProof, + )}`, + ); + const dp = await ws.cryptoApi.signDepositPermission({ + coinPriv: coin.coinPriv, + coinPub: coin.coinPub, + contractTermsHash: contractData.contractTermsHash, + denomPubHash: coin.denomPubHash, + denomKeyType: denom.denomPub.cipher, + denomSig: coin.denomSig, + exchangeBaseUrl: coin.exchangeBaseUrl, + feeDeposit: denom.fees.feeDeposit, + merchantPub: contractData.merchantPub, + refundDeadline: contractData.refundDeadline, + spendAmount: payCoinSel.coinContributions[i], + timestamp: contractData.timestamp, + wireInfoHash, + ageCommitmentProof: coin.ageCommitmentProof, + requiredMinimumAge: contractData.minimumAge, + }); + depositPermissions.push(dp); + } + return depositPermissions; +} + +/** + * Run the operation handler for a payment + * and return the result as a {@link ConfirmPayResult}. + */ +export async function runPayForConfirmPay( + ws: InternalWalletState, + proposalId: string, +): Promise { + const res = await processPurchasePay(ws, proposalId, { forceNow: true }); + switch (res.type) { + case OperationAttemptResultType.Finished: { + const purchase = await ws.db + .mktx((x) => [x.purchases]) + .runReadOnly(async (tx) => { + return tx.purchases.get(proposalId); + }); + if (!purchase?.download) { + throw Error("purchase record not available anymore"); + } + return { + type: ConfirmPayResultType.Done, + contractTerms: purchase.download.contractTermsRaw, + transactionId: makeEventId(TransactionType.Payment, proposalId), + }; + } + case OperationAttemptResultType.Error: { + // We hide transient errors from the caller. + const opRetry = await ws.db + .mktx((x) => [x.operationRetries]) + .runReadOnly(async (tx) => + tx.operationRetries.get(RetryTags.byPaymentProposalId(proposalId)), + ); + const maxRetry = 3; + const numRetry = opRetry?.retryInfo.retryCounter ?? 0; + if ( + res.errorDetail.code === + TalerErrorCode.WALLET_PAY_MERCHANT_SERVER_ERROR && + numRetry < maxRetry + ) { + // Pretend the operation is pending instead of reporting + // an error, but only up to maxRetry attempts. + await storeOperationPending( + ws, + RetryTags.byPaymentProposalId(proposalId), + ); + return { + type: ConfirmPayResultType.Pending, + lastError: opRetry?.lastError, + transactionId: makeEventId(TransactionType.Payment, proposalId), + }; + } else { + // FIXME: allocate error code! + await storeOperationError( + ws, + RetryTags.byPaymentProposalId(proposalId), + res.errorDetail, + ); + throw Error("payment failed"); + } + } + case OperationAttemptResultType.Pending: + await storeOperationPending( + ws, + `${PendingTaskType.Purchase}:${proposalId}`, + ); + return { + type: ConfirmPayResultType.Pending, + transactionId: makeEventId(TransactionType.Payment, proposalId), + lastError: undefined, + }; + case OperationAttemptResultType.Longpoll: + throw Error("unexpected processPurchasePay result (longpoll)"); + default: + assertUnreachable(res); + } +} + +/** + * Confirm payment for a proposal previously claimed by the wallet. + */ +export async function confirmPay( + ws: InternalWalletState, + proposalId: string, + sessionIdOverride?: string, + forcedCoinSel?: ForcedCoinSel, +): Promise { + logger.trace( + `executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`, + ); + const proposal = await ws.db + .mktx((x) => [x.purchases]) + .runReadOnly(async (tx) => { + return tx.purchases.get(proposalId); + }); + + if (!proposal) { + throw Error(`proposal with id ${proposalId} not found`); + } + + const d = proposal.download; + if (!d) { + throw Error("proposal is in invalid state"); + } + + const existingPurchase = await ws.db + .mktx((x) => [x.purchases]) + .runReadWrite(async (tx) => { + const purchase = await tx.purchases.get(proposalId); + if ( + purchase && + sessionIdOverride !== undefined && + sessionIdOverride != purchase.lastSessionId + ) { + logger.trace(`changing session ID to ${sessionIdOverride}`); + purchase.lastSessionId = sessionIdOverride; + await tx.purchases.put(purchase); + } + return purchase; + }); + + if (existingPurchase && existingPurchase.payInfo) { + logger.trace("confirmPay: submitting payment for existing purchase"); + return runPayForConfirmPay(ws, proposalId); + } + + logger.trace("confirmPay: purchase record does not exist yet"); + + const contractData = d.contractData; + + let maybeCoinSelection: PayCoinSelection | undefined = undefined; + + maybeCoinSelection = await selectPayCoinsNew(ws, { + auditors: contractData.allowedAuditors, + exchanges: contractData.allowedExchanges, + wireMethod: contractData.wireMethod, + contractTermsAmount: contractData.amount, + depositFeeLimit: contractData.maxDepositFee, + wireFeeAmortization: contractData.wireFeeAmortization ?? 1, + wireFeeLimit: contractData.maxWireFee, + prevPayCoins: [], + requiredMinimumAge: contractData.minimumAge, + forcedSelection: forcedCoinSel, + }); + + logger.trace("coin selection result", maybeCoinSelection); + + if (!maybeCoinSelection) { + // Should not happen, since checkPay should be called first + // FIXME: Actually, this should be handled gracefully, + // and the status should be stored in the DB. + logger.warn("not confirming payment, insufficient coins"); + throw Error("insufficient balance"); + } + + const coinSelection = maybeCoinSelection; + + const depositPermissions = await generateDepositPermissions( + ws, + coinSelection, + d.contractData, + ); + + const payCostInfo = await getTotalPaymentCost(ws, coinSelection); + + let sessionId: string | undefined; + if (sessionIdOverride) { + sessionId = sessionIdOverride; + } else { + sessionId = proposal.downloadSessionId; + } + + logger.trace( + `recording payment on ${proposal.orderId} with session ID ${sessionId}`, + ); + + await ws.db + .mktx((x) => [ + x.purchases, + x.coins, + x.refreshGroups, + x.denominations, + x.coinAvailability, + ]) + .runReadWrite(async (tx) => { + const p = await tx.purchases.get(proposal.proposalId); + if (!p) { + return; + } + switch (p.status) { + case ProposalStatus.Proposed: + p.payInfo = { + payCoinSelection: coinSelection, + payCoinSelectionUid: encodeCrock(getRandomBytes(16)), + totalPayCost: payCostInfo, + coinDepositPermissions: depositPermissions, + }; + p.lastSessionId = sessionId; + p.timestampAccept = TalerProtocolTimestamp.now(); + p.status = ProposalStatus.Paying; + await tx.purchases.put(p); + await spendCoins(ws, tx, { + allocationId: `proposal:${p.proposalId}`, + coinPubs: coinSelection.coinPubs, + contributions: coinSelection.coinContributions, + refreshReason: RefreshReason.PayMerchant, + }); + break; + case ProposalStatus.Paid: + case ProposalStatus.Paying: + default: + break; + } + }); + + ws.notify({ + type: NotificationType.ProposalAccepted, + proposalId: proposal.proposalId, + }); + + return runPayForConfirmPay(ws, proposalId); +} + +export async function processPurchase( + ws: InternalWalletState, + proposalId: string, + options: { + forceNow?: boolean; + } = {}, +): Promise { + const purchase = await ws.db + .mktx((x) => [x.purchases]) + .runReadOnly(async (tx) => { + return tx.purchases.get(proposalId); + }); + if (!purchase) { + return { + type: OperationAttemptResultType.Error, + errorDetail: { + // FIXME: allocate more specific error code + code: TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION, + hint: `trying to pay for purchase that is not in the database`, + proposalId: proposalId, + }, + }; + } + + switch (purchase.status) { + case ProposalStatus.DownloadingProposal: + return processDownloadProposal(ws, proposalId, options); + case ProposalStatus.Paying: + case ProposalStatus.PayingReplay: + return processPurchasePay(ws, proposalId, options); + case ProposalStatus.QueryingAutoRefund: + case ProposalStatus.QueryingAutoRefund: + case ProposalStatus.AbortingWithRefund: + return processPurchaseQueryRefund(ws, proposalId, options); + case ProposalStatus.ProposalDownloadFailed: + case ProposalStatus.Paid: + case ProposalStatus.AbortingWithRefund: + case ProposalStatus.RepurchaseDetected: + return { + type: OperationAttemptResultType.Finished, + result: undefined, + }; + default: + throw Error(`unexpected purchase status (${purchase.status})`); + } +} + +export async function processPurchasePay( + ws: InternalWalletState, + proposalId: string, + options: { + forceNow?: boolean; + } = {}, +): Promise { + const purchase = await ws.db + .mktx((x) => [x.purchases]) + .runReadOnly(async (tx) => { + return tx.purchases.get(proposalId); + }); + if (!purchase) { + return { + type: OperationAttemptResultType.Error, + errorDetail: { + // FIXME: allocate more specific error code + code: TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION, + hint: `trying to pay for purchase that is not in the database`, + proposalId: proposalId, + }, + }; + } + switch (purchase.status) { + case ProposalStatus.Paying: + case ProposalStatus.PayingReplay: + break; + default: + return OperationAttemptResult.finishedEmpty(); + } + logger.trace(`processing purchase pay ${proposalId}`); + + const sessionId = purchase.lastSessionId; + + logger.trace(`paying with session ID ${sessionId}`); + const payInfo = purchase.payInfo; + checkDbInvariant(!!payInfo, "payInfo"); + + const download = await expectProposalDownload(purchase); + if (!purchase.merchantPaySig) { + const payUrl = new URL( + `orders/${download.contractData.orderId}/pay`, + download.contractData.merchantBaseUrl, + ).href; + + let depositPermissions: CoinDepositPermission[]; + + if (purchase.payInfo?.coinDepositPermissions) { + depositPermissions = purchase.payInfo.coinDepositPermissions; + } else { + // FIXME: also cache! + depositPermissions = await generateDepositPermissions( + ws, + payInfo.payCoinSelection, + download.contractData, + ); + } + + const reqBody = { + coins: depositPermissions, + session_id: purchase.lastSessionId, + }; + + logger.trace( + "making pay request ... ", + JSON.stringify(reqBody, undefined, 2), + ); + + const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () => + ws.http.postJson(payUrl, reqBody, { + timeout: getPayRequestTimeout(purchase), + }), + ); + + logger.trace(`got resp ${JSON.stringify(resp)}`); + + if (resp.status >= 500 && resp.status <= 599) { + const errDetails = await readUnexpectedResponseDetails(resp); + return { + type: OperationAttemptResultType.Error, + errorDetail: makeErrorDetail( + TalerErrorCode.WALLET_PAY_MERCHANT_SERVER_ERROR, + { + requestError: errDetails, + }, + ), + }; + } + + if (resp.status === HttpStatusCode.BadRequest) { + const errDetails = await readUnexpectedResponseDetails(resp); + logger.warn("unexpected 400 response for /pay"); + logger.warn(j2s(errDetails)); + await ws.db + .mktx((x) => [x.purchases]) + .runReadWrite(async (tx) => { + const purch = await tx.purchases.get(proposalId); + if (!purch) { + return; + } + // FIXME: Should be some "PayPermanentlyFailed" and error info should be stored + purch.status = ProposalStatus.PaymentAbortFinished; + await tx.purchases.put(purch); + }); + throw makePendingOperationFailedError( + errDetails, + TransactionType.Payment, + proposalId, + ); + } + + if (resp.status === HttpStatusCode.Conflict) { + const err = await readTalerErrorResponse(resp); + if ( + err.code === + TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_INSUFFICIENT_FUNDS + ) { + // Do this in the background, as it might take some time + handleInsufficientFunds(ws, proposalId, err).catch(async (e) => { + console.log("handling insufficient funds failed"); + + await scheduleRetry(ws, RetryTags.forPay(purchase), { + code: TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION, + message: "unexpected exception", + hint: "unexpected exception", + details: { + exception: e.toString(), + }, + }); + }); + + return { + type: OperationAttemptResultType.Pending, + result: undefined, + }; + } + } + + const merchantResp = await readSuccessResponseJsonOrThrow( + resp, + codecForMerchantPayResponse(), + ); + + logger.trace("got success from pay URL", merchantResp); + + const merchantPub = download.contractData.merchantPub; + const { valid } = await ws.cryptoApi.isValidPaymentSignature({ + contractHash: download.contractData.contractTermsHash, + merchantPub, + sig: merchantResp.sig, + }); + + if (!valid) { + logger.error("merchant payment signature invalid"); + // FIXME: properly display error + throw Error("merchant payment signature invalid"); + } + + await storeFirstPaySuccess(ws, proposalId, sessionId, merchantResp.sig); + await unblockBackup(ws, proposalId); + } else { + const payAgainUrl = new URL( + `orders/${download.contractData.orderId}/paid`, + download.contractData.merchantBaseUrl, + ).href; + const reqBody = { + sig: purchase.merchantPaySig, + h_contract: download.contractData.contractTermsHash, + session_id: sessionId ?? "", + }; + logger.trace(`/paid request body: ${j2s(reqBody)}`); + const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () => + ws.http.postJson(payAgainUrl, reqBody), + ); + logger.trace(`/paid response status: ${resp.status}`); + if (resp.status !== 204) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, + getHttpResponseErrorDetails(resp), + "/paid failed", + ); + } + await storePayReplaySuccess(ws, proposalId, sessionId); + await unblockBackup(ws, proposalId); + } + + ws.notify({ + type: NotificationType.PayOperationSuccess, + proposalId: purchase.proposalId, + }); + + return OperationAttemptResult.finishedEmpty(); +} + +export async function refuseProposal( + ws: InternalWalletState, + proposalId: string, +): Promise { + const success = await ws.db + .mktx((x) => [x.purchases]) + .runReadWrite(async (tx) => { + const proposal = await tx.purchases.get(proposalId); + if (!proposal) { + logger.trace(`proposal ${proposalId} not found, won't refuse proposal`); + return false; + } + if (proposal.status !== ProposalStatus.Proposed) { + return false; + } + proposal.status = ProposalStatus.ProposalRefused; + await tx.purchases.put(proposal); + return true; + }); + if (success) { + ws.notify({ + type: NotificationType.ProposalRefused, + }); + } +} + +export async function prepareRefund( + ws: InternalWalletState, + talerRefundUri: string, +): Promise { + const parseResult = parseRefundUri(talerRefundUri); + + logger.trace("preparing refund offer", parseResult); + + if (!parseResult) { + throw Error("invalid refund URI"); + } + + const purchase = await ws.db + .mktx((x) => [x.purchases]) + .runReadOnly(async (tx) => { + return tx.purchases.indexes.byMerchantUrlAndOrderId.get([ + parseResult.merchantBaseUrl, + parseResult.orderId, + ]); + }); + + if (!purchase) { + throw Error( + `no purchase for the taler://refund/ URI (${talerRefundUri}) was found`, + ); + } + + const awaiting = await queryAndSaveAwaitingRefund(ws, purchase); + const summary = await calculateRefundSummary(purchase); + const proposalId = purchase.proposalId; + + const { contractData: c } = await expectProposalDownload(purchase); + + return { + proposalId, + effectivePaid: Amounts.stringify(summary.amountEffectivePaid), + gone: Amounts.stringify(summary.amountRefundGone), + granted: Amounts.stringify(summary.amountRefundGranted), + pending: summary.pendingAtExchange, + awaiting: Amounts.stringify(awaiting), + info: { + contractTermsHash: c.contractTermsHash, + merchant: c.merchant, + orderId: c.orderId, + products: c.products, + summary: c.summary, + fulfillmentMessage: c.fulfillmentMessage, + summary_i18n: c.summaryI18n, + fulfillmentMessage_i18n: c.fulfillmentMessageI18n, + }, + }; +} + +function getRefundKey(d: MerchantCoinRefundStatus): string { + return `${d.coin_pub}-${d.rtransaction_id}`; +} + +async function applySuccessfulRefund( + tx: GetReadWriteAccess<{ + coins: typeof WalletStoresV1.coins; + denominations: typeof WalletStoresV1.denominations; + }>, + p: PurchaseRecord, + refreshCoinsMap: Record, + r: MerchantCoinRefundSuccessStatus, +): Promise { + // FIXME: check signature before storing it as valid! + + const refundKey = getRefundKey(r); + const coin = await tx.coins.get(r.coin_pub); + if (!coin) { + logger.warn("coin not found, can't apply refund"); + return; + } + const denom = await tx.denominations.get([ + coin.exchangeBaseUrl, + coin.denomPubHash, + ]); + if (!denom) { + throw Error("inconsistent database"); + } + refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub }; + const refundAmount = Amounts.parseOrThrow(r.refund_amount); + const refundFee = denom.fees.feeRefund; + coin.status = CoinStatus.Dormant; + coin.currentAmount = Amounts.add(coin.currentAmount, refundAmount).amount; + coin.currentAmount = Amounts.sub(coin.currentAmount, refundFee).amount; + logger.trace(`coin amount after is ${Amounts.stringify(coin.currentAmount)}`); + await tx.coins.put(coin); + + const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl + .iter(coin.exchangeBaseUrl) + .toArray(); + + const amountLeft = Amounts.sub( + Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount)) + .amount, + denom.fees.feeRefund, + ).amount; + + const totalRefreshCostBound = getTotalRefreshCost( + allDenoms, + DenominationRecord.toDenomInfo(denom), + amountLeft, + ); + + p.refunds[refundKey] = { + type: RefundState.Applied, + obtainedTime: AbsoluteTime.toTimestamp(AbsoluteTime.now()), + executionTime: r.execution_time, + refundAmount: Amounts.parseOrThrow(r.refund_amount), + refundFee: denom.fees.feeRefund, + totalRefreshCostBound, + coinPub: r.coin_pub, + rtransactionId: r.rtransaction_id, + }; +} + +async function storePendingRefund( + tx: GetReadWriteAccess<{ + denominations: typeof WalletStoresV1.denominations; + coins: typeof WalletStoresV1.coins; + }>, + p: PurchaseRecord, + r: MerchantCoinRefundFailureStatus, +): Promise { + const refundKey = getRefundKey(r); + + const coin = await tx.coins.get(r.coin_pub); + if (!coin) { + logger.warn("coin not found, can't apply refund"); + return; + } + const denom = await tx.denominations.get([ + coin.exchangeBaseUrl, + coin.denomPubHash, + ]); + + if (!denom) { + throw Error("inconsistent database"); + } + + const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl + .iter(coin.exchangeBaseUrl) + .toArray(); + + const amountLeft = Amounts.sub( + Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount)) + .amount, + denom.fees.feeRefund, + ).amount; + + const totalRefreshCostBound = getTotalRefreshCost( + allDenoms, + DenominationRecord.toDenomInfo(denom), + amountLeft, + ); + + p.refunds[refundKey] = { + type: RefundState.Pending, + obtainedTime: AbsoluteTime.toTimestamp(AbsoluteTime.now()), + executionTime: r.execution_time, + refundAmount: Amounts.parseOrThrow(r.refund_amount), + refundFee: denom.fees.feeRefund, + totalRefreshCostBound, + coinPub: r.coin_pub, + rtransactionId: r.rtransaction_id, + }; +} + +async function storeFailedRefund( + tx: GetReadWriteAccess<{ + coins: typeof WalletStoresV1.coins; + denominations: typeof WalletStoresV1.denominations; + }>, + p: PurchaseRecord, + refreshCoinsMap: Record, + r: MerchantCoinRefundFailureStatus, +): Promise { + const refundKey = getRefundKey(r); + + const coin = await tx.coins.get(r.coin_pub); + if (!coin) { + logger.warn("coin not found, can't apply refund"); + return; + } + const denom = await tx.denominations.get([ + coin.exchangeBaseUrl, + coin.denomPubHash, + ]); + + if (!denom) { + throw Error("inconsistent database"); + } + + const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl + .iter(coin.exchangeBaseUrl) + .toArray(); + + const amountLeft = Amounts.sub( + Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount)) + .amount, + denom.fees.feeRefund, + ).amount; + + const totalRefreshCostBound = getTotalRefreshCost( + allDenoms, + DenominationRecord.toDenomInfo(denom), + amountLeft, + ); + + p.refunds[refundKey] = { + type: RefundState.Failed, + obtainedTime: TalerProtocolTimestamp.now(), + executionTime: r.execution_time, + refundAmount: Amounts.parseOrThrow(r.refund_amount), + refundFee: denom.fees.feeRefund, + totalRefreshCostBound, + coinPub: r.coin_pub, + rtransactionId: r.rtransaction_id, + }; + + if (p.status === ProposalStatus.AbortingWithRefund) { + // Refund failed because the merchant didn't even try to deposit + // the coin yet, so we try to refresh. + if (r.exchange_code === TalerErrorCode.EXCHANGE_REFUND_DEPOSIT_NOT_FOUND) { + const coin = await tx.coins.get(r.coin_pub); + if (!coin) { + logger.warn("coin not found, can't apply refund"); + return; + } + const denom = await tx.denominations.get([ + coin.exchangeBaseUrl, + coin.denomPubHash, + ]); + if (!denom) { + logger.warn("denomination for coin missing"); + return; + } + const payCoinSelection = p.payInfo?.payCoinSelection; + if (!payCoinSelection) { + logger.warn("no pay coin selection, can't apply refund"); + return; + } + let contrib: AmountJson | undefined; + for (let i = 0; i < payCoinSelection.coinPubs.length; i++) { + if (payCoinSelection.coinPubs[i] === r.coin_pub) { + contrib = payCoinSelection.coinContributions[i]; + } + } + if (contrib) { + coin.currentAmount = Amounts.add(coin.currentAmount, contrib).amount; + coin.currentAmount = Amounts.sub( + coin.currentAmount, + denom.fees.feeRefund, + ).amount; + } + refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub }; + await tx.coins.put(coin); + } + } +} + +async function acceptRefunds( + ws: InternalWalletState, + proposalId: string, + refunds: MerchantCoinRefundStatus[], + reason: RefundReason, +): Promise { + logger.trace("handling refunds", refunds); + const now = TalerProtocolTimestamp.now(); + + await ws.db + .mktx((x) => [ + x.purchases, + x.coins, + x.coinAvailability, + x.denominations, + x.refreshGroups, + ]) + .runReadWrite(async (tx) => { + const p = await tx.purchases.get(proposalId); + if (!p) { + logger.error("purchase not found, not adding refunds"); + return; + } + + const refreshCoinsMap: Record = {}; + + for (const refundStatus of refunds) { + const refundKey = getRefundKey(refundStatus); + const existingRefundInfo = p.refunds[refundKey]; + + const isPermanentFailure = + refundStatus.type === "failure" && + refundStatus.exchange_status >= 400 && + refundStatus.exchange_status < 500; + + // Already failed. + if (existingRefundInfo?.type === RefundState.Failed) { + continue; + } + + // Already applied. + if (existingRefundInfo?.type === RefundState.Applied) { + continue; + } + + // Still pending. + if ( + refundStatus.type === "failure" && + !isPermanentFailure && + existingRefundInfo?.type === RefundState.Pending + ) { + continue; + } + + // Invariant: (!existingRefundInfo) || (existingRefundInfo === Pending) + + if (refundStatus.type === "success") { + await applySuccessfulRefund(tx, p, refreshCoinsMap, refundStatus); + } else if (isPermanentFailure) { + await storeFailedRefund(tx, p, refreshCoinsMap, refundStatus); + } else { + await storePendingRefund(tx, p, refundStatus); + } + } + + const refreshCoinsPubs = Object.values(refreshCoinsMap); + if (refreshCoinsPubs.length > 0) { + await createRefreshGroup( + ws, + tx, + refreshCoinsPubs, + RefreshReason.Refund, + ); + } + + // Are we done with querying yet, or do we need to do another round + // after a retry delay? + let queryDone = true; + + let numPendingRefunds = 0; + for (const ri of Object.values(p.refunds)) { + switch (ri.type) { + case RefundState.Pending: + numPendingRefunds++; + break; + } + } + + if (numPendingRefunds > 0) { + queryDone = false; + } + + if (queryDone) { + p.timestampLastRefundStatus = now; + if (p.status === ProposalStatus.AbortingWithRefund) { + p.status = ProposalStatus.PaymentAbortFinished; + } else if (p.status === ProposalStatus.QueryingAutoRefund) { + const autoRefundDeadline = p.autoRefundDeadline; + checkDbInvariant(!!autoRefundDeadline); + if ( + AbsoluteTime.isExpired( + AbsoluteTime.fromTimestamp(autoRefundDeadline), + ) + ) { + p.status = ProposalStatus.Paid; + } + } else if (p.status === ProposalStatus.QueryingRefund) { + p.status = ProposalStatus.Paid; + } + logger.trace("refund query done"); + } else { + // No error, but we need to try again! + p.timestampLastRefundStatus = now; + logger.trace("refund query not done"); + } + + await tx.purchases.put(p); + }); + + ws.notify({ + type: NotificationType.RefundQueried, + }); +} + +async function calculateRefundSummary( + p: PurchaseRecord, +): Promise { + const download = await expectProposalDownload(p); + let amountRefundGranted = Amounts.getZero( + download.contractData.amount.currency, + ); + let amountRefundGone = Amounts.getZero(download.contractData.amount.currency); + + let pendingAtExchange = false; + + const payInfo = p.payInfo; + if (!payInfo) { + throw Error("can't calculate refund summary without payInfo"); + } + + Object.keys(p.refunds).forEach((rk) => { + const refund = p.refunds[rk]; + if (refund.type === RefundState.Pending) { + pendingAtExchange = true; + } + if ( + refund.type === RefundState.Applied || + refund.type === RefundState.Pending + ) { + amountRefundGranted = Amounts.add( + amountRefundGranted, + Amounts.sub( + refund.refundAmount, + refund.refundFee, + refund.totalRefreshCostBound, + ).amount, + ).amount; + } else { + amountRefundGone = Amounts.add( + amountRefundGone, + refund.refundAmount, + ).amount; + } + }); + return { + amountEffectivePaid: payInfo.totalPayCost, + amountRefundGone, + amountRefundGranted, + pendingAtExchange, + }; +} + +/** + * Summary of the refund status of a purchase. + */ +export interface RefundSummary { + pendingAtExchange: boolean; + amountEffectivePaid: AmountJson; + amountRefundGranted: AmountJson; + amountRefundGone: AmountJson; +} + +/** + * Accept a refund, return the contract hash for the contract + * that was involved in the refund. + */ +export async function applyRefund( + ws: InternalWalletState, + talerRefundUri: string, +): Promise { + const parseResult = parseRefundUri(talerRefundUri); + + logger.trace("applying refund", parseResult); + + if (!parseResult) { + throw Error("invalid refund URI"); + } + + const purchase = await ws.db + .mktx((x) => [x.purchases]) + .runReadOnly(async (tx) => { + return tx.purchases.indexes.byMerchantUrlAndOrderId.get([ + parseResult.merchantBaseUrl, + parseResult.orderId, + ]); + }); + + if (!purchase) { + throw Error( + `no purchase for the taler://refund/ URI (${talerRefundUri}) was found`, + ); + } + + return applyRefundFromPurchaseId(ws, purchase.proposalId); +} + +export async function applyRefundFromPurchaseId( + ws: InternalWalletState, + proposalId: string, +): Promise { + logger.trace("applying refund for purchase", proposalId); + + logger.info("processing purchase for refund"); + const success = await ws.db + .mktx((x) => [x.purchases]) + .runReadWrite(async (tx) => { + const p = await tx.purchases.get(proposalId); + if (!p) { + logger.error("no purchase found for refund URL"); + return false; + } + if (p.status === ProposalStatus.Paid) { + p.status = ProposalStatus.QueryingRefund; + } + await tx.purchases.put(p); + return true; + }); + + if (success) { + ws.notify({ + type: NotificationType.RefundStarted, + }); + await processPurchaseQueryRefund(ws, proposalId, { + forceNow: true, + waitForAutoRefund: false, + }); + } + + const purchase = await ws.db + .mktx((x) => [x.purchases]) + .runReadOnly(async (tx) => { + return tx.purchases.get(proposalId); + }); + + if (!purchase) { + throw Error("purchase no longer exists"); + } + + const summary = await calculateRefundSummary(purchase); + const download = await expectProposalDownload(purchase); + + return { + contractTermsHash: download.contractData.contractTermsHash, + proposalId: purchase.proposalId, + transactionId: makeEventId(TransactionType.Payment, proposalId), //FIXME: can we have the tx id of the refund + amountEffectivePaid: Amounts.stringify(summary.amountEffectivePaid), + amountRefundGone: Amounts.stringify(summary.amountRefundGone), + amountRefundGranted: Amounts.stringify(summary.amountRefundGranted), + pendingAtExchange: summary.pendingAtExchange, + info: { + contractTermsHash: download.contractData.contractTermsHash, + merchant: download.contractData.merchant, + orderId: download.contractData.orderId, + products: download.contractData.products, + summary: download.contractData.summary, + fulfillmentMessage: download.contractData.fulfillmentMessage, + summary_i18n: download.contractData.summaryI18n, + fulfillmentMessage_i18n: download.contractData.fulfillmentMessageI18n, + }, + }; +} + +async function queryAndSaveAwaitingRefund( + ws: InternalWalletState, + purchase: PurchaseRecord, + waitForAutoRefund?: boolean, +): Promise { + const download = await expectProposalDownload(purchase); + const requestUrl = new URL( + `orders/${download.contractData.orderId}`, + download.contractData.merchantBaseUrl, + ); + requestUrl.searchParams.set( + "h_contract", + download.contractData.contractTermsHash, + ); + // Long-poll for one second + if (waitForAutoRefund) { + requestUrl.searchParams.set("timeout_ms", "1000"); + requestUrl.searchParams.set("await_refund_obtained", "yes"); + logger.trace("making long-polling request for auto-refund"); + } + const resp = await ws.http.get(requestUrl.href); + const orderStatus = await readSuccessResponseJsonOrThrow( + resp, + codecForMerchantOrderStatusPaid(), + ); + if (!orderStatus.refunded) { + // Wait for retry ... + return Amounts.getZero(download.contractData.amount.currency); + } + + const refundAwaiting = Amounts.sub( + Amounts.parseOrThrow(orderStatus.refund_amount), + Amounts.parseOrThrow(orderStatus.refund_taken), + ).amount; + + if ( + purchase.refundAmountAwaiting === undefined || + Amounts.cmp(refundAwaiting, purchase.refundAmountAwaiting) !== 0 + ) { + await ws.db + .mktx((x) => [x.purchases]) + .runReadWrite(async (tx) => { + const p = await tx.purchases.get(purchase.proposalId); + if (!p) { + logger.warn("purchase does not exist anymore"); + return; + } + p.refundAmountAwaiting = refundAwaiting; + await tx.purchases.put(p); + }); + } + + return refundAwaiting; +} + +export async function processPurchaseQueryRefund( + ws: InternalWalletState, + proposalId: string, + options: { + forceNow?: boolean; + waitForAutoRefund?: boolean; + } = {}, +): Promise { + logger.trace(`processing refund query for proposal ${proposalId}`); + const waitForAutoRefund = options.waitForAutoRefund ?? false; + const purchase = await ws.db + .mktx((x) => [x.purchases]) + .runReadOnly(async (tx) => { + return tx.purchases.get(proposalId); + }); + if (!purchase) { + return OperationAttemptResult.finishedEmpty(); + } + + if ( + !( + purchase.status === ProposalStatus.QueryingAutoRefund || + purchase.status === ProposalStatus.QueryingRefund || + purchase.status === ProposalStatus.AbortingWithRefund + ) + ) { + return OperationAttemptResult.finishedEmpty(); + } + + const download = await expectProposalDownload(purchase); + + if (purchase.timestampFirstSuccessfulPay) { + if ( + !purchase.autoRefundDeadline || + !AbsoluteTime.isExpired( + AbsoluteTime.fromTimestamp(purchase.autoRefundDeadline), + ) + ) { + const awaitingAmount = await queryAndSaveAwaitingRefund( + ws, + purchase, + waitForAutoRefund, + ); + if (Amounts.isZero(awaitingAmount)) { + return OperationAttemptResult.finishedEmpty(); + } + } + + const requestUrl = new URL( + `orders/${download.contractData.orderId}/refund`, + download.contractData.merchantBaseUrl, + ); + + logger.trace(`making refund request to ${requestUrl.href}`); + + const request = await ws.http.postJson(requestUrl.href, { + h_contract: download.contractData.contractTermsHash, + }); + + const refundResponse = await readSuccessResponseJsonOrThrow( + request, + codecForMerchantOrderRefundPickupResponse(), + ); + + await acceptRefunds( + ws, + proposalId, + refundResponse.refunds, + RefundReason.NormalRefund, + ); + } else if (purchase.status === ProposalStatus.AbortingWithRefund) { + const requestUrl = new URL( + `orders/${download.contractData.orderId}/abort`, + download.contractData.merchantBaseUrl, + ); + + const abortingCoins: AbortingCoin[] = []; + + const payCoinSelection = purchase.payInfo?.payCoinSelection; + if (!payCoinSelection) { + throw Error("can't abort, no coins selected"); + } + + await ws.db + .mktx((x) => [x.coins]) + .runReadOnly(async (tx) => { + for (let i = 0; i < payCoinSelection.coinPubs.length; i++) { + const coinPub = payCoinSelection.coinPubs[i]; + const coin = await tx.coins.get(coinPub); + checkDbInvariant(!!coin, "expected coin to be present"); + abortingCoins.push({ + coin_pub: coinPub, + contribution: Amounts.stringify( + payCoinSelection.coinContributions[i], + ), + exchange_url: coin.exchangeBaseUrl, + }); + } + }); + + const abortReq: AbortRequest = { + h_contract: download.contractData.contractTermsHash, + coins: abortingCoins, + }; + + logger.trace(`making order abort request to ${requestUrl.href}`); + + const request = await ws.http.postJson(requestUrl.href, abortReq); + const abortResp = await readSuccessResponseJsonOrThrow( + request, + codecForAbortResponse(), + ); + + const refunds: MerchantCoinRefundStatus[] = []; + + if (abortResp.refunds.length != abortingCoins.length) { + // FIXME: define error code! + throw Error("invalid order abort response"); + } + + for (let i = 0; i < abortResp.refunds.length; i++) { + const r = abortResp.refunds[i]; + refunds.push({ + ...r, + coin_pub: payCoinSelection.coinPubs[i], + refund_amount: Amounts.stringify(payCoinSelection.coinContributions[i]), + rtransaction_id: 0, + execution_time: AbsoluteTime.toTimestamp( + AbsoluteTime.addDuration( + AbsoluteTime.fromTimestamp(download.contractData.timestamp), + Duration.fromSpec({ seconds: 1 }), + ), + ), + }); + } + await acceptRefunds(ws, proposalId, refunds, RefundReason.AbortRefund); + } + return OperationAttemptResult.finishedEmpty(); +} + +export async function abortFailedPayWithRefund( + ws: InternalWalletState, + proposalId: string, +): Promise { + await ws.db + .mktx((x) => [x.purchases]) + .runReadWrite(async (tx) => { + const purchase = await tx.purchases.get(proposalId); + if (!purchase) { + throw Error("purchase not found"); + } + if (purchase.timestampFirstSuccessfulPay) { + // No point in aborting it. We don't even report an error. + logger.warn(`tried to abort successful payment`); + return; + } + if (purchase.status === ProposalStatus.Paying) { + purchase.status = ProposalStatus.AbortingWithRefund; + } + await tx.purchases.put(purchase); + }); + processPurchaseQueryRefund(ws, proposalId, { + forceNow: true, + }).catch((e) => { + logger.trace(`error during refund processing after abort pay: ${e}`); + }); +} diff --git a/packages/taler-wallet-core/src/operations/pay-peer.ts b/packages/taler-wallet-core/src/operations/pay-peer.ts new file mode 100644 index 000000000..e9185a9d4 --- /dev/null +++ b/packages/taler-wallet-core/src/operations/pay-peer.ts @@ -0,0 +1,847 @@ +/* + This file is part of GNU Taler + (C) 2022 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +/** + * Imports. + */ +import { + AbsoluteTime, + AcceptPeerPullPaymentRequest, + AcceptPeerPullPaymentResponse, + AcceptPeerPushPaymentRequest, + AcceptPeerPushPaymentResponse, + AgeCommitmentProof, + AmountJson, + Amounts, + AmountString, + buildCodecForObject, + CheckPeerPullPaymentRequest, + CheckPeerPullPaymentResponse, + CheckPeerPushPaymentRequest, + CheckPeerPushPaymentResponse, + Codec, + codecForAmountString, + codecForAny, + codecForExchangeGetContractResponse, + constructPayPullUri, + constructPayPushUri, + ContractTermsUtil, + decodeCrock, + Duration, + eddsaGetPublic, + encodeCrock, + ExchangePurseDeposits, + ExchangePurseMergeRequest, + ExchangeReservePurseRequest, + getRandomBytes, + InitiatePeerPullPaymentRequest, + InitiatePeerPullPaymentResponse, + InitiatePeerPushPaymentRequest, + InitiatePeerPushPaymentResponse, + j2s, + Logger, + parsePayPullUri, + parsePayPushUri, + RefreshReason, + strcmp, + TalerProtocolTimestamp, + TransactionType, + UnblindedSignature, + WalletAccountMergeFlags, +} from "@gnu-taler/taler-util"; +import { + CoinStatus, + MergeReserveInfo, + WithdrawalGroupStatus, + WalletStoresV1, + WithdrawalRecordType, +} from "../db.js"; +import { InternalWalletState } from "../internal-wallet-state.js"; +import { readSuccessResponseJsonOrThrow } from "../util/http.js"; +import { checkDbInvariant } from "../util/invariants.js"; +import { GetReadOnlyAccess } from "../util/query.js"; +import { spendCoins, makeEventId } from "../operations/common.js"; +import { updateExchangeFromUrl } from "./exchanges.js"; +import { internalCreateWithdrawalGroup } from "./withdraw.js"; + +const logger = new Logger("operations/peer-to-peer.ts"); + +export interface PeerCoinSelection { + exchangeBaseUrl: string; + + /** + * Info of Coins that were selected. + */ + coins: { + coinPub: string; + coinPriv: string; + contribution: AmountString; + denomPubHash: string; + denomSig: UnblindedSignature; + ageCommitmentProof: AgeCommitmentProof | undefined; + }[]; + + /** + * How much of the deposit fees is the customer paying? + */ + depositFees: AmountJson; +} + +interface CoinInfo { + /** + * Public key of the coin. + */ + coinPub: string; + + coinPriv: string; + + /** + * Deposit fee for the coin. + */ + feeDeposit: AmountJson; + + value: AmountJson; + + denomPubHash: string; + + denomSig: UnblindedSignature; + + maxAge: number; + ageCommitmentProof?: AgeCommitmentProof; +} + +export async function selectPeerCoins( + ws: InternalWalletState, + tx: GetReadOnlyAccess<{ + exchanges: typeof WalletStoresV1.exchanges; + denominations: typeof WalletStoresV1.denominations; + coins: typeof WalletStoresV1.coins; + }>, + instructedAmount: AmountJson, +): Promise { + const exchanges = await tx.exchanges.iter().toArray(); + for (const exch of exchanges) { + if (exch.detailsPointer?.currency !== instructedAmount.currency) { + continue; + } + const coins = ( + await tx.coins.indexes.byBaseUrl.getAll(exch.baseUrl) + ).filter((x) => x.status === CoinStatus.Fresh); + const coinInfos: CoinInfo[] = []; + for (const coin of coins) { + const denom = await ws.getDenomInfo( + ws, + tx, + coin.exchangeBaseUrl, + coin.denomPubHash, + ); + if (!denom) { + throw Error("denom not found"); + } + coinInfos.push({ + coinPub: coin.coinPub, + feeDeposit: denom.feeDeposit, + value: denom.value, + denomPubHash: denom.denomPubHash, + coinPriv: coin.coinPriv, + denomSig: coin.denomSig, + maxAge: coin.maxAge, + ageCommitmentProof: coin.ageCommitmentProof, + }); + } + if (coinInfos.length === 0) { + continue; + } + coinInfos.sort( + (o1, o2) => + -Amounts.cmp(o1.value, o2.value) || + strcmp(o1.denomPubHash, o2.denomPubHash), + ); + let amountAcc = Amounts.getZero(instructedAmount.currency); + let depositFeesAcc = Amounts.getZero(instructedAmount.currency); + const resCoins: { + coinPub: string; + coinPriv: string; + contribution: AmountString; + denomPubHash: string; + denomSig: UnblindedSignature; + ageCommitmentProof: AgeCommitmentProof | undefined; + }[] = []; + for (const coin of coinInfos) { + if (Amounts.cmp(amountAcc, instructedAmount) >= 0) { + const res: PeerCoinSelection = { + exchangeBaseUrl: exch.baseUrl, + coins: resCoins, + depositFees: depositFeesAcc, + }; + return res; + } + const gap = Amounts.add( + coin.feeDeposit, + Amounts.sub(instructedAmount, amountAcc).amount, + ).amount; + const contrib = Amounts.min(gap, coin.value); + amountAcc = Amounts.add( + amountAcc, + Amounts.sub(contrib, coin.feeDeposit).amount, + ).amount; + depositFeesAcc = Amounts.add(depositFeesAcc, coin.feeDeposit).amount; + resCoins.push({ + coinPriv: coin.coinPriv, + coinPub: coin.coinPub, + contribution: Amounts.stringify(contrib), + denomPubHash: coin.denomPubHash, + denomSig: coin.denomSig, + ageCommitmentProof: coin.ageCommitmentProof, + }); + } + continue; + } + return undefined; +} + +export async function initiatePeerToPeerPush( + ws: InternalWalletState, + req: InitiatePeerPushPaymentRequest, +): Promise { + const instructedAmount = Amounts.parseOrThrow(req.amount); + + const pursePair = await ws.cryptoApi.createEddsaKeypair({}); + const mergePair = await ws.cryptoApi.createEddsaKeypair({}); + + const purseExpiration: TalerProtocolTimestamp = AbsoluteTime.toTimestamp( + AbsoluteTime.addDuration( + AbsoluteTime.now(), + Duration.fromSpec({ days: 2 }), + ), + ); + + const contractTerms = { + ...req.partialContractTerms, + purse_expiration: purseExpiration, + amount: req.amount, + }; + + const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms); + + const econtractResp = await ws.cryptoApi.encryptContractForMerge({ + contractTerms, + mergePriv: mergePair.priv, + pursePriv: pursePair.priv, + pursePub: pursePair.pub, + }); + + const coinSelRes: PeerCoinSelection | undefined = await ws.db + .mktx((x) => [ + x.exchanges, + x.coins, + x.coinAvailability, + x.denominations, + x.refreshGroups, + x.peerPullPaymentInitiations, + x.peerPushPaymentInitiations, + ]) + .runReadWrite(async (tx) => { + const sel = await selectPeerCoins(ws, tx, instructedAmount); + if (!sel) { + return undefined; + } + + await spendCoins(ws, tx, { + allocationId: `peer-push:${pursePair.pub}`, + coinPubs: sel.coins.map((x) => x.coinPub), + contributions: sel.coins.map((x) => + Amounts.parseOrThrow(x.contribution), + ), + refreshReason: RefreshReason.PayPeerPush, + }); + + await tx.peerPushPaymentInitiations.add({ + amount: Amounts.stringify(instructedAmount), + contractPriv: econtractResp.contractPriv, + contractTerms, + exchangeBaseUrl: sel.exchangeBaseUrl, + mergePriv: mergePair.priv, + mergePub: mergePair.pub, + // FIXME: only set this later! + purseCreated: true, + purseExpiration: purseExpiration, + pursePriv: pursePair.priv, + pursePub: pursePair.pub, + timestampCreated: TalerProtocolTimestamp.now(), + }); + + return sel; + }); + logger.info(`selected p2p coins (push): ${j2s(coinSelRes)}`); + + if (!coinSelRes) { + throw Error("insufficient balance"); + } + + const purseSigResp = await ws.cryptoApi.signPurseCreation({ + hContractTerms, + mergePub: mergePair.pub, + minAge: 0, + purseAmount: Amounts.stringify(instructedAmount), + purseExpiration, + pursePriv: pursePair.priv, + }); + + const depositSigsResp = await ws.cryptoApi.signPurseDeposits({ + exchangeBaseUrl: coinSelRes.exchangeBaseUrl, + pursePub: pursePair.pub, + coins: coinSelRes.coins, + }); + + const createPurseUrl = new URL( + `purses/${pursePair.pub}/create`, + coinSelRes.exchangeBaseUrl, + ); + + const httpResp = await ws.http.postJson(createPurseUrl.href, { + amount: Amounts.stringify(instructedAmount), + merge_pub: mergePair.pub, + purse_sig: purseSigResp.sig, + h_contract_terms: hContractTerms, + purse_expiration: purseExpiration, + deposits: depositSigsResp.deposits, + min_age: 0, + econtract: econtractResp.econtract, + }); + + const resp = await httpResp.json(); + + logger.info(`resp: ${j2s(resp)}`); + + if (httpResp.status !== 200) { + throw Error("got error response from exchange"); + } + + return { + contractPriv: econtractResp.contractPriv, + mergePriv: mergePair.priv, + pursePub: pursePair.pub, + exchangeBaseUrl: coinSelRes.exchangeBaseUrl, + talerUri: constructPayPushUri({ + exchangeBaseUrl: coinSelRes.exchangeBaseUrl, + contractPriv: econtractResp.contractPriv, + }), + transactionId: makeEventId(TransactionType.PeerPushDebit, pursePair.pub), + }; +} + +interface ExchangePurseStatus { + balance: AmountString; +} + +export const codecForExchangePurseStatus = (): Codec => + buildCodecForObject() + .property("balance", codecForAmountString()) + .build("ExchangePurseStatus"); + +export async function checkPeerPushPayment( + ws: InternalWalletState, + req: CheckPeerPushPaymentRequest, +): Promise { + // FIXME: Check if existing record exists! + + const uri = parsePayPushUri(req.talerUri); + + if (!uri) { + throw Error("got invalid taler://pay-push URI"); + } + + const exchangeBaseUrl = uri.exchangeBaseUrl; + + await updateExchangeFromUrl(ws, exchangeBaseUrl); + + const contractPriv = uri.contractPriv; + const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv))); + + const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl); + + const contractHttpResp = await ws.http.get(getContractUrl.href); + + const contractResp = await readSuccessResponseJsonOrThrow( + contractHttpResp, + codecForExchangeGetContractResponse(), + ); + + const pursePub = contractResp.purse_pub; + + const dec = await ws.cryptoApi.decryptContractForMerge({ + ciphertext: contractResp.econtract, + contractPriv: contractPriv, + pursePub: pursePub, + }); + + const getPurseUrl = new URL(`purses/${pursePub}/deposit`, exchangeBaseUrl); + + const purseHttpResp = await ws.http.get(getPurseUrl.href); + + const purseStatus = await readSuccessResponseJsonOrThrow( + purseHttpResp, + codecForExchangePurseStatus(), + ); + + const peerPushPaymentIncomingId = encodeCrock(getRandomBytes(32)); + + await ws.db + .mktx((x) => [x.peerPushPaymentIncoming]) + .runReadWrite(async (tx) => { + await tx.peerPushPaymentIncoming.add({ + peerPushPaymentIncomingId, + contractPriv: contractPriv, + exchangeBaseUrl: exchangeBaseUrl, + mergePriv: dec.mergePriv, + pursePub: pursePub, + timestamp: TalerProtocolTimestamp.now(), + contractTerms: dec.contractTerms, + }); + }); + + return { + amount: purseStatus.balance, + contractTerms: dec.contractTerms, + peerPushPaymentIncomingId, + }; +} + +export function talerPaytoFromExchangeReserve( + exchangeBaseUrl: string, + reservePub: string, +): string { + const url = new URL(exchangeBaseUrl); + let proto: string; + if (url.protocol === "http:") { + proto = "taler-reserve-http"; + } else if (url.protocol === "https:") { + proto = "taler-reserve"; + } else { + throw Error(`unsupported exchange base URL protocol (${url.protocol})`); + } + + let path = url.pathname; + if (!path.endsWith("/")) { + path = path + "/"; + } + + return `payto://${proto}/${url.host}${url.pathname}${reservePub}`; +} + +async function getMergeReserveInfo( + ws: InternalWalletState, + req: { + exchangeBaseUrl: string; + }, +): Promise { + // We have to eagerly create the key pair outside of the transaction, + // due to the async crypto API. + const newReservePair = await ws.cryptoApi.createEddsaKeypair({}); + + const mergeReserveInfo: MergeReserveInfo = await ws.db + .mktx((x) => [x.exchanges, x.withdrawalGroups]) + .runReadWrite(async (tx) => { + const ex = await tx.exchanges.get(req.exchangeBaseUrl); + checkDbInvariant(!!ex); + if (ex.currentMergeReserveInfo) { + return ex.currentMergeReserveInfo; + } + await tx.exchanges.put(ex); + ex.currentMergeReserveInfo = { + reservePriv: newReservePair.priv, + reservePub: newReservePair.pub, + }; + return ex.currentMergeReserveInfo; + }); + + return mergeReserveInfo; +} + +export async function acceptPeerPushPayment( + ws: InternalWalletState, + req: AcceptPeerPushPaymentRequest, +): Promise { + const peerInc = await ws.db + .mktx((x) => [x.peerPushPaymentIncoming]) + .runReadOnly(async (tx) => { + return tx.peerPushPaymentIncoming.get(req.peerPushPaymentIncomingId); + }); + + if (!peerInc) { + throw Error( + `can't accept unknown incoming p2p push payment (${req.peerPushPaymentIncomingId})`, + ); + } + + await updateExchangeFromUrl(ws, peerInc.exchangeBaseUrl); + + const amount = Amounts.parseOrThrow(peerInc.contractTerms.amount); + + const mergeReserveInfo = await getMergeReserveInfo(ws, { + exchangeBaseUrl: peerInc.exchangeBaseUrl, + }); + + const mergeTimestamp = TalerProtocolTimestamp.now(); + + const reservePayto = talerPaytoFromExchangeReserve( + peerInc.exchangeBaseUrl, + mergeReserveInfo.reservePub, + ); + + const sigRes = await ws.cryptoApi.signPurseMerge({ + contractTermsHash: ContractTermsUtil.hashContractTerms( + peerInc.contractTerms, + ), + flags: WalletAccountMergeFlags.MergeFullyPaidPurse, + mergePriv: peerInc.mergePriv, + mergeTimestamp: mergeTimestamp, + purseAmount: Amounts.stringify(amount), + purseExpiration: peerInc.contractTerms.purse_expiration, + purseFee: Amounts.stringify(Amounts.getZero(amount.currency)), + pursePub: peerInc.pursePub, + reservePayto, + reservePriv: mergeReserveInfo.reservePriv, + }); + + const mergePurseUrl = new URL( + `purses/${peerInc.pursePub}/merge`, + peerInc.exchangeBaseUrl, + ); + + const mergeReq: ExchangePurseMergeRequest = { + payto_uri: reservePayto, + merge_timestamp: mergeTimestamp, + merge_sig: sigRes.mergeSig, + reserve_sig: sigRes.accountSig, + }; + + const mergeHttpReq = await ws.http.postJson(mergePurseUrl.href, mergeReq); + + logger.info(`merge request: ${j2s(mergeReq)}`); + const res = await readSuccessResponseJsonOrThrow(mergeHttpReq, codecForAny()); + logger.info(`merge response: ${j2s(res)}`); + + const wg = await internalCreateWithdrawalGroup(ws, { + amount, + wgInfo: { + withdrawalType: WithdrawalRecordType.PeerPushCredit, + contractTerms: peerInc.contractTerms, + }, + exchangeBaseUrl: peerInc.exchangeBaseUrl, + reserveStatus: WithdrawalGroupStatus.QueryingStatus, + reserveKeyPair: { + priv: mergeReserveInfo.reservePriv, + pub: mergeReserveInfo.reservePub, + }, + }); + + return { + transactionId: makeEventId( + TransactionType.PeerPushCredit, + wg.withdrawalGroupId, + ), + }; +} + +/** + * FIXME: Bad name! + */ +export async function acceptPeerPullPayment( + ws: InternalWalletState, + req: AcceptPeerPullPaymentRequest, +): Promise { + const peerPullInc = await ws.db + .mktx((x) => [x.peerPullPaymentIncoming]) + .runReadOnly(async (tx) => { + return tx.peerPullPaymentIncoming.get(req.peerPullPaymentIncomingId); + }); + + if (!peerPullInc) { + throw Error( + `can't accept unknown incoming p2p pull payment (${req.peerPullPaymentIncomingId})`, + ); + } + + const instructedAmount = Amounts.parseOrThrow( + peerPullInc.contractTerms.amount, + ); + const coinSelRes: PeerCoinSelection | undefined = await ws.db + .mktx((x) => [ + x.exchanges, + x.coins, + x.denominations, + x.refreshGroups, + x.peerPullPaymentIncoming, + x.coinAvailability, + ]) + .runReadWrite(async (tx) => { + const sel = await selectPeerCoins(ws, tx, instructedAmount); + if (!sel) { + return undefined; + } + + await spendCoins(ws, tx, { + allocationId: `peer-pull:${req.peerPullPaymentIncomingId}`, + coinPubs: sel.coins.map((x) => x.coinPub), + contributions: sel.coins.map((x) => + Amounts.parseOrThrow(x.contribution), + ), + refreshReason: RefreshReason.PayPeerPull, + }); + + const pi = await tx.peerPullPaymentIncoming.get( + req.peerPullPaymentIncomingId, + ); + if (!pi) { + throw Error(); + } + pi.accepted = true; + await tx.peerPullPaymentIncoming.put(pi); + + return sel; + }); + logger.info(`selected p2p coins (pull): ${j2s(coinSelRes)}`); + + if (!coinSelRes) { + throw Error("insufficient balance"); + } + + const pursePub = peerPullInc.pursePub; + + const depositSigsResp = await ws.cryptoApi.signPurseDeposits({ + exchangeBaseUrl: coinSelRes.exchangeBaseUrl, + pursePub, + coins: coinSelRes.coins, + }); + + const purseDepositUrl = new URL( + `purses/${pursePub}/deposit`, + coinSelRes.exchangeBaseUrl, + ); + + const depositPayload: ExchangePurseDeposits = { + deposits: depositSigsResp.deposits, + }; + + const httpResp = await ws.http.postJson(purseDepositUrl.href, depositPayload); + const resp = await readSuccessResponseJsonOrThrow(httpResp, codecForAny()); + logger.trace(`purse deposit response: ${j2s(resp)}`); + + return { + transactionId: makeEventId( + TransactionType.PeerPullDebit, + req.peerPullPaymentIncomingId, + ), + }; +} + +export async function checkPeerPullPayment( + ws: InternalWalletState, + req: CheckPeerPullPaymentRequest, +): Promise { + const uri = parsePayPullUri(req.talerUri); + + if (!uri) { + throw Error("got invalid taler://pay-push URI"); + } + + const exchangeBaseUrl = uri.exchangeBaseUrl; + const contractPriv = uri.contractPriv; + const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv))); + + const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl); + + const contractHttpResp = await ws.http.get(getContractUrl.href); + + const contractResp = await readSuccessResponseJsonOrThrow( + contractHttpResp, + codecForExchangeGetContractResponse(), + ); + + const pursePub = contractResp.purse_pub; + + const dec = await ws.cryptoApi.decryptContractForDeposit({ + ciphertext: contractResp.econtract, + contractPriv: contractPriv, + pursePub: pursePub, + }); + + const getPurseUrl = new URL(`purses/${pursePub}/merge`, exchangeBaseUrl); + + const purseHttpResp = await ws.http.get(getPurseUrl.href); + + const purseStatus = await readSuccessResponseJsonOrThrow( + purseHttpResp, + codecForExchangePurseStatus(), + ); + + const peerPullPaymentIncomingId = encodeCrock(getRandomBytes(32)); + + await ws.db + .mktx((x) => [x.peerPullPaymentIncoming]) + .runReadWrite(async (tx) => { + await tx.peerPullPaymentIncoming.add({ + peerPullPaymentIncomingId, + contractPriv: contractPriv, + exchangeBaseUrl: exchangeBaseUrl, + pursePub: pursePub, + timestampCreated: TalerProtocolTimestamp.now(), + contractTerms: dec.contractTerms, + paid: false, + accepted: false, + }); + }); + + return { + amount: purseStatus.balance, + contractTerms: dec.contractTerms, + peerPullPaymentIncomingId, + }; +} + +/** + * Initiate a peer pull payment. + */ +export async function initiatePeerRequestForPay( + ws: InternalWalletState, + req: InitiatePeerPullPaymentRequest, +): Promise { + await updateExchangeFromUrl(ws, req.exchangeBaseUrl); + + const mergeReserveInfo = await getMergeReserveInfo(ws, { + exchangeBaseUrl: req.exchangeBaseUrl, + }); + + const mergeTimestamp = TalerProtocolTimestamp.now(); + + const pursePair = await ws.cryptoApi.createEddsaKeypair({}); + const mergePair = await ws.cryptoApi.createEddsaKeypair({}); + + const purseExpiration: TalerProtocolTimestamp = AbsoluteTime.toTimestamp( + AbsoluteTime.addDuration( + AbsoluteTime.now(), + Duration.fromSpec({ days: 2 }), + ), + ); + + const reservePayto = talerPaytoFromExchangeReserve( + req.exchangeBaseUrl, + mergeReserveInfo.reservePub, + ); + + const contractTerms = { + ...req.partialContractTerms, + amount: req.amount, + purse_expiration: purseExpiration, + }; + + const econtractResp = await ws.cryptoApi.encryptContractForDeposit({ + contractTerms, + pursePriv: pursePair.priv, + pursePub: pursePair.pub, + }); + + const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms); + + const purseFee = Amounts.stringify( + Amounts.getZero(Amounts.parseOrThrow(req.amount).currency), + ); + + const sigRes = await ws.cryptoApi.signReservePurseCreate({ + contractTermsHash: hContractTerms, + flags: WalletAccountMergeFlags.CreateWithPurseFee, + mergePriv: mergePair.priv, + mergeTimestamp: mergeTimestamp, + purseAmount: req.amount, + purseExpiration: purseExpiration, + purseFee: purseFee, + pursePriv: pursePair.priv, + pursePub: pursePair.pub, + reservePayto, + reservePriv: mergeReserveInfo.reservePriv, + }); + + await ws.db + .mktx((x) => [x.peerPullPaymentInitiations]) + .runReadWrite(async (tx) => { + await tx.peerPullPaymentInitiations.put({ + amount: req.amount, + contractTerms, + exchangeBaseUrl: req.exchangeBaseUrl, + pursePriv: pursePair.priv, + pursePub: pursePair.pub, + }); + }); + + const reservePurseReqBody: ExchangeReservePurseRequest = { + merge_sig: sigRes.mergeSig, + merge_timestamp: mergeTimestamp, + h_contract_terms: hContractTerms, + merge_pub: mergePair.pub, + min_age: 0, + purse_expiration: purseExpiration, + purse_fee: purseFee, + purse_pub: pursePair.pub, + purse_sig: sigRes.purseSig, + purse_value: req.amount, + reserve_sig: sigRes.accountSig, + econtract: econtractResp.econtract, + }; + + logger.info(`reserve purse request: ${j2s(reservePurseReqBody)}`); + + const reservePurseMergeUrl = new URL( + `reserves/${mergeReserveInfo.reservePub}/purse`, + req.exchangeBaseUrl, + ); + + const httpResp = await ws.http.postJson( + reservePurseMergeUrl.href, + reservePurseReqBody, + ); + + const resp = await readSuccessResponseJsonOrThrow(httpResp, codecForAny()); + + logger.info(`reserve merge response: ${j2s(resp)}`); + + const wg = await internalCreateWithdrawalGroup(ws, { + amount: Amounts.parseOrThrow(req.amount), + wgInfo: { + withdrawalType: WithdrawalRecordType.PeerPullCredit, + contractTerms, + contractPriv: econtractResp.contractPriv, + }, + exchangeBaseUrl: req.exchangeBaseUrl, + reserveStatus: WithdrawalGroupStatus.QueryingStatus, + reserveKeyPair: { + priv: mergeReserveInfo.reservePriv, + pub: mergeReserveInfo.reservePub, + }, + }); + + return { + talerUri: constructPayPullUri({ + exchangeBaseUrl: req.exchangeBaseUrl, + contractPriv: econtractResp.contractPriv, + }), + transactionId: makeEventId( + TransactionType.PeerPullCredit, + wg.withdrawalGroupId, + ), + }; +} diff --git a/packages/taler-wallet-core/src/operations/pay.ts b/packages/taler-wallet-core/src/operations/pay.ts deleted file mode 100644 index 6757b79b4..000000000 --- a/packages/taler-wallet-core/src/operations/pay.ts +++ /dev/null @@ -1,1893 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019-2022 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see - */ - -/** - * Implementation of the payment operation, including downloading and - * claiming of proposals. - * - * @author Florian Dold - */ - -/** - * Imports. - */ -import { GlobalIDB } from "@gnu-taler/idb-bridge"; -import { - AbsoluteTime, - AgeRestriction, - AmountJson, - Amounts, - codecForContractTerms, - codecForMerchantPayResponse, - codecForProposal, - CoinDepositPermission, - ConfirmPayResult, - ConfirmPayResultType, - ContractTerms, - ContractTermsUtil, - DenominationInfo, - Duration, - encodeCrock, - ForcedCoinSel, - getRandomBytes, - HttpStatusCode, - j2s, - Logger, - NotificationType, - parsePaytoUri, - parsePayUri, - PayCoinSelection, - PreparePayResult, - PreparePayResultType, - RefreshReason, - strcmp, - TalerErrorCode, - TalerErrorDetail, - TalerProtocolTimestamp, - TransactionType, - URL, -} from "@gnu-taler/taler-util"; -import { EddsaKeypair } from "../crypto/cryptoImplementation.js"; -import { - AbortStatus, - AllowedAuditorInfo, - AllowedExchangeInfo, - BackupProviderStateTag, - CoinRecord, - CoinStatus, - DenominationRecord, - ProposalRecord, - ProposalStatus, - PurchaseRecord, - WalletContractData, -} from "../db.js"; -import { - makeErrorDetail, - makePendingOperationFailedError, - TalerError, - TalerProtocolViolationError, -} from "../errors.js"; -import { - EXCHANGE_COINS_LOCK, - InternalWalletState, -} from "../internal-wallet-state.js"; -import { PendingTaskType } from "../pending-types.js"; -import { assertUnreachable } from "../util/assertUnreachable.js"; -import { - CoinSelectionTally, - PreviousPayCoins, - tallyFees, -} from "../util/coinSelection.js"; -import { - getHttpResponseErrorDetails, - readSuccessResponseJsonOrErrorCode, - readSuccessResponseJsonOrThrow, - readTalerErrorResponse, - readUnexpectedResponseDetails, - throwUnexpectedRequestError, -} from "../util/http.js"; -import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js"; -import { - OperationAttemptResult, - OperationAttemptResultType, - RetryInfo, - RetryTags, - scheduleRetry, -} from "../util/retries.js"; -import { - spendCoins, - storeOperationError, - storeOperationPending, -} from "../wallet.js"; -import { getExchangeDetails } from "./exchanges.js"; -import { getTotalRefreshCost } from "./refresh.js"; -import { makeEventId } from "./transactions.js"; - -/** - * Logger. - */ -const logger = new Logger("pay.ts"); - -/** - * Compute the total cost of a payment to the customer. - * - * This includes the amount taken by the merchant, fees (wire/deposit) contributed - * by the customer, refreshing fees, fees for withdraw-after-refresh and "trimmings" - * of coins that are too small to spend. - */ -export async function getTotalPaymentCost( - ws: InternalWalletState, - pcs: PayCoinSelection, -): Promise { - return ws.db - .mktx((x) => [x.coins, x.denominations]) - .runReadOnly(async (tx) => { - const costs: AmountJson[] = []; - for (let i = 0; i < pcs.coinPubs.length; i++) { - const coin = await tx.coins.get(pcs.coinPubs[i]); - if (!coin) { - throw Error("can't calculate payment cost, coin not found"); - } - const denom = await tx.denominations.get([ - coin.exchangeBaseUrl, - coin.denomPubHash, - ]); - if (!denom) { - throw Error( - "can't calculate payment cost, denomination for coin not found", - ); - } - const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl - .iter(coin.exchangeBaseUrl) - .filter((x) => - Amounts.isSameCurrency( - DenominationRecord.getValue(x), - pcs.coinContributions[i], - ), - ); - const amountLeft = Amounts.sub( - DenominationRecord.getValue(denom), - pcs.coinContributions[i], - ).amount; - const refreshCost = getTotalRefreshCost( - allDenoms, - DenominationRecord.toDenomInfo(denom), - amountLeft, - ); - costs.push(pcs.coinContributions[i]); - costs.push(refreshCost); - } - const zero = Amounts.getZero(pcs.paymentAmount.currency); - return Amounts.sum([zero, ...costs]).amount; - }); -} - -export interface CoinSelectionRequest { - amount: AmountJson; - - allowedAuditors: AllowedAuditorInfo[]; - allowedExchanges: AllowedExchangeInfo[]; - - /** - * Timestamp of the contract. - */ - timestamp: TalerProtocolTimestamp; - - wireMethod: string; - - wireFeeAmortization: number; - - maxWireFee: AmountJson; - - maxDepositFee: AmountJson; - - /** - * Minimum age requirement for the coin selection. - * - * When present, only select coins with either no age restriction - * or coins with an age commitment that matches the minimum age. - */ - minimumAge?: number; -} - -/** - * Record all information that is necessary to - * pay for a proposal in the wallet's database. - */ -async function recordConfirmPay( - ws: InternalWalletState, - proposal: ProposalRecord, - coinSelection: PayCoinSelection, - coinDepositPermissions: CoinDepositPermission[], - sessionIdOverride: string | undefined, -): Promise { - const d = proposal.download; - if (!d) { - throw Error("proposal is in invalid state"); - } - let sessionId; - if (sessionIdOverride) { - sessionId = sessionIdOverride; - } else { - sessionId = proposal.downloadSessionId; - } - logger.trace( - `recording payment on ${proposal.orderId} with session ID ${sessionId}`, - ); - const payCostInfo = await getTotalPaymentCost(ws, coinSelection); - const t: PurchaseRecord = { - abortStatus: AbortStatus.None, - download: d, - lastSessionId: sessionId, - payCoinSelection: coinSelection, - payCoinSelectionUid: encodeCrock(getRandomBytes(32)), - totalPayCost: payCostInfo, - coinDepositPermissions, - timestampAccept: AbsoluteTime.toTimestamp(AbsoluteTime.now()), - timestampLastRefundStatus: undefined, - proposalId: proposal.proposalId, - refundQueryRequested: false, - timestampFirstSuccessfulPay: undefined, - autoRefundDeadline: undefined, - refundAwaiting: undefined, - paymentSubmitPending: true, - refunds: {}, - merchantPaySig: undefined, - noncePriv: proposal.noncePriv, - noncePub: proposal.noncePub, - }; - - await ws.db - .mktx((x) => [ - x.proposals, - x.purchases, - x.coins, - x.refreshGroups, - x.denominations, - x.coinAvailability, - ]) - .runReadWrite(async (tx) => { - const p = await tx.proposals.get(proposal.proposalId); - if (p) { - p.proposalStatus = ProposalStatus.Accepted; - await tx.proposals.put(p); - } - await tx.purchases.put(t); - await spendCoins(ws, tx, { - allocationId: `proposal:${t.proposalId}`, - coinPubs: coinSelection.coinPubs, - contributions: coinSelection.coinContributions, - refreshReason: RefreshReason.PayMerchant, - }); - }); - - ws.notify({ - type: NotificationType.ProposalAccepted, - proposalId: proposal.proposalId, - }); - return t; -} - -async function failProposalPermanently( - ws: InternalWalletState, - proposalId: string, - err: TalerErrorDetail, -): Promise { - await ws.db - .mktx((x) => [x.proposals]) - .runReadWrite(async (tx) => { - const p = await tx.proposals.get(proposalId); - if (!p) { - return; - } - p.proposalStatus = ProposalStatus.PermanentlyFailed; - await tx.proposals.put(p); - }); -} - -function getProposalRequestTimeout(retryInfo?: RetryInfo): Duration { - return Duration.clamp({ - lower: Duration.fromSpec({ seconds: 1 }), - upper: Duration.fromSpec({ seconds: 60 }), - value: retryInfo ? RetryInfo.getDuration(retryInfo) : Duration.fromSpec({}), - }); -} - -function getPayRequestTimeout(purchase: PurchaseRecord): Duration { - return Duration.multiply( - { d_ms: 15000 }, - 1 + purchase.payCoinSelection.coinPubs.length / 5, - ); -} - -export function extractContractData( - parsedContractTerms: ContractTerms, - contractTermsHash: string, - merchantSig: string, -): WalletContractData { - const amount = Amounts.parseOrThrow(parsedContractTerms.amount); - let maxWireFee: AmountJson; - if (parsedContractTerms.max_wire_fee) { - maxWireFee = Amounts.parseOrThrow(parsedContractTerms.max_wire_fee); - } else { - maxWireFee = Amounts.getZero(amount.currency); - } - return { - amount, - contractTermsHash: contractTermsHash, - fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "", - merchantBaseUrl: parsedContractTerms.merchant_base_url, - merchantPub: parsedContractTerms.merchant_pub, - merchantSig, - orderId: parsedContractTerms.order_id, - summary: parsedContractTerms.summary, - autoRefund: parsedContractTerms.auto_refund, - maxWireFee, - payDeadline: parsedContractTerms.pay_deadline, - refundDeadline: parsedContractTerms.refund_deadline, - wireFeeAmortization: parsedContractTerms.wire_fee_amortization || 1, - allowedAuditors: parsedContractTerms.auditors.map((x) => ({ - auditorBaseUrl: x.url, - auditorPub: x.auditor_pub, - })), - allowedExchanges: parsedContractTerms.exchanges.map((x) => ({ - exchangeBaseUrl: x.url, - exchangePub: x.master_pub, - })), - timestamp: parsedContractTerms.timestamp, - wireMethod: parsedContractTerms.wire_method, - wireInfoHash: parsedContractTerms.h_wire, - maxDepositFee: Amounts.parseOrThrow(parsedContractTerms.max_fee), - merchant: parsedContractTerms.merchant, - products: parsedContractTerms.products, - summaryI18n: parsedContractTerms.summary_i18n, - minimumAge: parsedContractTerms.minimum_age, - deliveryDate: parsedContractTerms.delivery_date, - deliveryLocation: parsedContractTerms.delivery_location, - }; -} - -export async function processDownloadProposal( - ws: InternalWalletState, - proposalId: string, - options: object = {}, -): Promise { - const proposal = await ws.db - .mktx((x) => [x.proposals]) - .runReadOnly(async (tx) => { - return await tx.proposals.get(proposalId); - }); - - if (!proposal) { - return { - type: OperationAttemptResultType.Finished, - result: undefined, - }; - } - - if (proposal.proposalStatus != ProposalStatus.Downloading) { - return { - type: OperationAttemptResultType.Finished, - result: undefined, - }; - } - - const orderClaimUrl = new URL( - `orders/${proposal.orderId}/claim`, - proposal.merchantBaseUrl, - ).href; - logger.trace("downloading contract from '" + orderClaimUrl + "'"); - - const requestBody: { - nonce: string; - token?: string; - } = { - nonce: proposal.noncePub, - }; - if (proposal.claimToken) { - requestBody.token = proposal.claimToken; - } - - const opId = RetryTags.forProposalClaim(proposal); - const retryRecord = await ws.db - .mktx((x) => [x.operationRetries]) - .runReadOnly(async (tx) => { - return tx.operationRetries.get(opId); - }); - - // FIXME: Do this in the background using the new return value - const httpResponse = await ws.http.postJson(orderClaimUrl, requestBody, { - timeout: getProposalRequestTimeout(retryRecord?.retryInfo), - }); - const r = await readSuccessResponseJsonOrErrorCode( - httpResponse, - codecForProposal(), - ); - if (r.isError) { - switch (r.talerErrorResponse.code) { - case TalerErrorCode.MERCHANT_POST_ORDERS_ID_CLAIM_ALREADY_CLAIMED: - throw TalerError.fromDetail( - TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED, - { - orderId: proposal.orderId, - claimUrl: orderClaimUrl, - }, - "order already claimed (likely by other wallet)", - ); - default: - throwUnexpectedRequestError(httpResponse, r.talerErrorResponse); - } - } - const proposalResp = r.response; - - // The proposalResp contains the contract terms as raw JSON, - // as the coded to parse them doesn't necessarily round-trip. - // We need this raw JSON to compute the contract terms hash. - - // FIXME: Do better error handling, check if the - // contract terms have all their forgettable information still - // present. The wallet should never accept contract terms - // with missing information from the merchant. - - const isWellFormed = ContractTermsUtil.validateForgettable( - proposalResp.contract_terms, - ); - - if (!isWellFormed) { - logger.trace( - `malformed contract terms: ${j2s(proposalResp.contract_terms)}`, - ); - const err = makeErrorDetail( - TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED, - {}, - "validation for well-formedness failed", - ); - await failProposalPermanently(ws, proposalId, err); - throw makePendingOperationFailedError( - err, - TransactionType.Payment, - proposalId, - ); - } - - const contractTermsHash = ContractTermsUtil.hashContractTerms( - proposalResp.contract_terms, - ); - - logger.info(`Contract terms hash: ${contractTermsHash}`); - - let parsedContractTerms: ContractTerms; - - try { - parsedContractTerms = codecForContractTerms().decode( - proposalResp.contract_terms, - ); - } catch (e) { - const err = makeErrorDetail( - TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED, - {}, - `schema validation failed: ${e}`, - ); - await failProposalPermanently(ws, proposalId, err); - throw makePendingOperationFailedError( - err, - TransactionType.Payment, - proposalId, - ); - } - - const sigValid = await ws.cryptoApi.isValidContractTermsSignature({ - contractTermsHash, - merchantPub: parsedContractTerms.merchant_pub, - sig: proposalResp.sig, - }); - - if (!sigValid) { - const err = makeErrorDetail( - TalerErrorCode.WALLET_CONTRACT_TERMS_SIGNATURE_INVALID, - { - merchantPub: parsedContractTerms.merchant_pub, - orderId: parsedContractTerms.order_id, - }, - "merchant's signature on contract terms is invalid", - ); - await failProposalPermanently(ws, proposalId, err); - throw makePendingOperationFailedError( - err, - TransactionType.Payment, - proposalId, - ); - } - - const fulfillmentUrl = parsedContractTerms.fulfillment_url; - - const baseUrlForDownload = proposal.merchantBaseUrl; - const baseUrlFromContractTerms = parsedContractTerms.merchant_base_url; - - if (baseUrlForDownload !== baseUrlFromContractTerms) { - const err = makeErrorDetail( - TalerErrorCode.WALLET_CONTRACT_TERMS_BASE_URL_MISMATCH, - { - baseUrlForDownload, - baseUrlFromContractTerms, - }, - "merchant base URL mismatch", - ); - await failProposalPermanently(ws, proposalId, err); - throw makePendingOperationFailedError( - err, - TransactionType.Payment, - proposalId, - ); - } - - const contractData = extractContractData( - parsedContractTerms, - contractTermsHash, - proposalResp.sig, - ); - - logger.trace(`extracted contract data: ${j2s(contractData)}`); - - await ws.db - .mktx((x) => [x.purchases, x.proposals]) - .runReadWrite(async (tx) => { - const p = await tx.proposals.get(proposalId); - if (!p) { - return; - } - if (p.proposalStatus !== ProposalStatus.Downloading) { - return; - } - p.download = { - contractData, - contractTermsRaw: proposalResp.contract_terms, - }; - if ( - fulfillmentUrl && - (fulfillmentUrl.startsWith("http://") || - fulfillmentUrl.startsWith("https://")) - ) { - const differentPurchase = - await tx.purchases.indexes.byFulfillmentUrl.get(fulfillmentUrl); - if (differentPurchase) { - logger.warn("repurchase detected"); - p.proposalStatus = ProposalStatus.Repurchase; - p.repurchaseProposalId = differentPurchase.proposalId; - await tx.proposals.put(p); - return; - } - } - p.proposalStatus = ProposalStatus.Proposed; - await tx.proposals.put(p); - }); - - ws.notify({ - type: NotificationType.ProposalDownloaded, - proposalId: proposal.proposalId, - }); - - return { - type: OperationAttemptResultType.Finished, - result: undefined, - }; -} - -/** - * Download a proposal and store it in the database. - * Returns an id for it to retrieve it later. - * - * @param sessionId Current session ID, if the proposal is being - * downloaded in the context of a session ID. - */ -async function startDownloadProposal( - ws: InternalWalletState, - merchantBaseUrl: string, - orderId: string, - sessionId: string | undefined, - claimToken: string | undefined, - noncePriv: string | undefined, -): Promise { - const oldProposal = await ws.db - .mktx((x) => [x.proposals]) - .runReadOnly(async (tx) => { - return tx.proposals.indexes.byUrlAndOrderId.get([ - merchantBaseUrl, - orderId, - ]); - }); - - /* If we have already claimed this proposal with the same sessionId - * nonce and claim token, reuse it. */ - if ( - oldProposal && - oldProposal.downloadSessionId === sessionId && - (!noncePriv || oldProposal.noncePriv === noncePriv) && - oldProposal.claimToken === claimToken - ) { - await processDownloadProposal(ws, oldProposal.proposalId); - return oldProposal.proposalId; - } - - let noncePair: EddsaKeypair; - if (noncePriv) { - noncePair = { - priv: noncePriv, - pub: (await ws.cryptoApi.eddsaGetPublic({ priv: noncePriv })).pub, - }; - } else { - noncePair = await ws.cryptoApi.createEddsaKeypair({}); - } - - const { priv, pub } = noncePair; - const proposalId = encodeCrock(getRandomBytes(32)); - - const proposalRecord: ProposalRecord = { - download: undefined, - noncePriv: priv, - noncePub: pub, - claimToken, - timestamp: AbsoluteTime.toTimestamp(AbsoluteTime.now()), - merchantBaseUrl, - orderId, - proposalId: proposalId, - proposalStatus: ProposalStatus.Downloading, - repurchaseProposalId: undefined, - downloadSessionId: sessionId, - }; - - await ws.db - .mktx((x) => [x.proposals]) - .runReadWrite(async (tx) => { - const existingRecord = await tx.proposals.indexes.byUrlAndOrderId.get([ - merchantBaseUrl, - orderId, - ]); - if (existingRecord) { - // Created concurrently - return; - } - await tx.proposals.put(proposalRecord); - }); - - await processDownloadProposal(ws, proposalId); - return proposalId; -} - -async function storeFirstPaySuccess( - ws: InternalWalletState, - proposalId: string, - sessionId: string | undefined, - paySig: string, -): Promise { - const now = AbsoluteTime.toTimestamp(AbsoluteTime.now()); - await ws.db - .mktx((x) => [x.purchases]) - .runReadWrite(async (tx) => { - const purchase = await tx.purchases.get(proposalId); - - if (!purchase) { - logger.warn("purchase does not exist anymore"); - return; - } - const isFirst = purchase.timestampFirstSuccessfulPay === undefined; - if (!isFirst) { - logger.warn("payment success already stored"); - return; - } - purchase.timestampFirstSuccessfulPay = now; - purchase.paymentSubmitPending = false; - purchase.lastSessionId = sessionId; - purchase.merchantPaySig = paySig; - const protoAr = purchase.download.contractData.autoRefund; - if (protoAr) { - const ar = Duration.fromTalerProtocolDuration(protoAr); - logger.info("auto_refund present"); - purchase.refundQueryRequested = true; - purchase.autoRefundDeadline = AbsoluteTime.toTimestamp( - AbsoluteTime.addDuration(AbsoluteTime.now(), ar), - ); - } - await tx.purchases.put(purchase); - }); -} - -async function storePayReplaySuccess( - ws: InternalWalletState, - proposalId: string, - sessionId: string | undefined, -): Promise { - await ws.db - .mktx((x) => [x.purchases]) - .runReadWrite(async (tx) => { - const purchase = await tx.purchases.get(proposalId); - - if (!purchase) { - logger.warn("purchase does not exist anymore"); - return; - } - const isFirst = purchase.timestampFirstSuccessfulPay === undefined; - if (isFirst) { - throw Error("invalid payment state"); - } - purchase.paymentSubmitPending = false; - purchase.lastSessionId = sessionId; - await tx.purchases.put(purchase); - }); -} - -/** - * Handle a 409 Conflict response from the merchant. - * - * We do this by going through the coin history provided by the exchange and - * (1) verifying the signatures from the exchange - * (2) adjusting the remaining coin value and refreshing it - * (3) re-do coin selection with the bad coin removed - */ -async function handleInsufficientFunds( - ws: InternalWalletState, - proposalId: string, - err: TalerErrorDetail, -): Promise { - logger.trace("handling insufficient funds, trying to re-select coins"); - - const proposal = await ws.db - .mktx((x) => [x.purchases]) - .runReadOnly(async (tx) => { - return tx.purchases.get(proposalId); - }); - if (!proposal) { - return; - } - - logger.trace(`got error details: ${j2s(err)}`); - - const exchangeReply = (err as any).exchange_reply; - if ( - exchangeReply.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS - ) { - // FIXME: set as failed - if (logger.shouldLogTrace()) { - logger.trace("got exchange error reply (see below)"); - logger.trace(j2s(exchangeReply)); - } - throw Error(`unable to handle /pay error response (${exchangeReply.code})`); - } - - const brokenCoinPub = (exchangeReply as any).coin_pub; - logger.trace(`excluded broken coin pub=${brokenCoinPub}`); - - if (!brokenCoinPub) { - throw new TalerProtocolViolationError(); - } - - const { contractData } = proposal.download; - - const prevPayCoins: PreviousPayCoins = []; - - await ws.db - .mktx((x) => [x.coins, x.denominations]) - .runReadOnly(async (tx) => { - for (let i = 0; i < proposal.payCoinSelection.coinPubs.length; i++) { - const coinPub = proposal.payCoinSelection.coinPubs[i]; - if (coinPub === brokenCoinPub) { - continue; - } - const contrib = proposal.payCoinSelection.coinContributions[i]; - const coin = await tx.coins.get(coinPub); - if (!coin) { - continue; - } - const denom = await tx.denominations.get([ - coin.exchangeBaseUrl, - coin.denomPubHash, - ]); - if (!denom) { - continue; - } - prevPayCoins.push({ - coinPub, - contribution: contrib, - exchangeBaseUrl: coin.exchangeBaseUrl, - feeDeposit: denom.fees.feeDeposit, - }); - } - }); - - const res = await selectPayCoinsNew(ws, { - auditors: contractData.allowedAuditors, - exchanges: contractData.allowedExchanges, - wireMethod: contractData.wireMethod, - contractTermsAmount: contractData.amount, - depositFeeLimit: contractData.maxDepositFee, - wireFeeAmortization: contractData.wireFeeAmortization ?? 1, - wireFeeLimit: contractData.maxWireFee, - prevPayCoins, - requiredMinimumAge: contractData.minimumAge, - }); - - if (!res) { - logger.trace("insufficient funds for coin re-selection"); - return; - } - - logger.trace("re-selected coins"); - - await ws.db - .mktx((x) => [ - x.purchases, - x.coins, - x.coinAvailability, - x.denominations, - x.refreshGroups, - ]) - .runReadWrite(async (tx) => { - const p = await tx.purchases.get(proposalId); - if (!p) { - return; - } - p.payCoinSelection = res; - p.payCoinSelectionUid = encodeCrock(getRandomBytes(32)); - p.coinDepositPermissions = undefined; - await tx.purchases.put(p); - await spendCoins(ws, tx, { - allocationId: `proposal:${p.proposalId}`, - coinPubs: p.payCoinSelection.coinPubs, - contributions: p.payCoinSelection.coinContributions, - refreshReason: RefreshReason.PayMerchant, - }); - }); -} - -async function unblockBackup( - ws: InternalWalletState, - proposalId: string, -): Promise { - await ws.db - .mktx((x) => [x.backupProviders]) - .runReadWrite(async (tx) => { - await tx.backupProviders.indexes.byPaymentProposalId - .iter(proposalId) - .forEachAsync(async (bp) => { - if (bp.state.tag === BackupProviderStateTag.Retrying) { - bp.state = { - tag: BackupProviderStateTag.Ready, - nextBackupTimestamp: TalerProtocolTimestamp.now(), - }; - } - }); - }); -} - -export interface SelectPayCoinRequestNg { - exchanges: AllowedExchangeInfo[]; - auditors: AllowedAuditorInfo[]; - wireMethod: string; - contractTermsAmount: AmountJson; - depositFeeLimit: AmountJson; - wireFeeLimit: AmountJson; - wireFeeAmortization: number; - prevPayCoins?: PreviousPayCoins; - requiredMinimumAge?: number; - forcedSelection?: ForcedCoinSel; -} - -export type AvailableDenom = DenominationInfo & { - maxAge: number; - numAvailable: number; -}; - -export async function selectCandidates( - ws: InternalWalletState, - req: SelectPayCoinRequestNg, -): Promise<[AvailableDenom[], Record]> { - return await ws.db - .mktx((x) => [ - x.exchanges, - x.exchangeDetails, - x.denominations, - x.coinAvailability, - ]) - .runReadOnly(async (tx) => { - const denoms: AvailableDenom[] = []; - const exchanges = await tx.exchanges.iter().toArray(); - const wfPerExchange: Record = {}; - for (const exchange of exchanges) { - const exchangeDetails = await getExchangeDetails(tx, exchange.baseUrl); - if (exchangeDetails?.currency !== req.contractTermsAmount.currency) { - continue; - } - let wireMethodSupported = false; - for (const acc of exchangeDetails.wireInfo.accounts) { - const pp = parsePaytoUri(acc.payto_uri); - checkLogicInvariant(!!pp); - if (pp.targetType === req.wireMethod) { - wireMethodSupported = true; - break; - } - } - if (!wireMethodSupported) { - break; - } - exchangeDetails.wireInfo.accounts; - let accepted = false; - for (const allowedExchange of req.exchanges) { - if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) { - accepted = true; - break; - } - } - for (const allowedAuditor of req.auditors) { - for (const providedAuditor of exchangeDetails.auditors) { - if (allowedAuditor.auditorPub === providedAuditor.auditor_pub) { - accepted = true; - break; - } - } - } - if (!accepted) { - continue; - } - let ageLower = 0; - let ageUpper = AgeRestriction.AGE_UNRESTRICTED; - if (req.requiredMinimumAge) { - ageLower = req.requiredMinimumAge; - } - const myExchangeDenoms = - await tx.coinAvailability.indexes.byExchangeAgeAvailability.getAll( - GlobalIDB.KeyRange.bound( - [exchangeDetails.exchangeBaseUrl, ageLower, 1], - [ - exchangeDetails.exchangeBaseUrl, - ageUpper, - Number.MAX_SAFE_INTEGER, - ], - ), - ); - // FIXME: Check that the individual denomination is audited! - // FIXME: Should we exclude denominations that are - // not spendable anymore? - for (const denomAvail of myExchangeDenoms) { - const denom = await tx.denominations.get([ - denomAvail.exchangeBaseUrl, - denomAvail.denomPubHash, - ]); - checkDbInvariant(!!denom); - if (denom.isRevoked || !denom.isOffered) { - continue; - } - denoms.push({ - ...DenominationRecord.toDenomInfo(denom), - numAvailable: denomAvail.freshCoinCount ?? 0, - maxAge: denomAvail.maxAge, - }); - } - } - // Sort by available amount (descending), deposit fee (ascending) and - // denomPub (ascending) if deposit fee is the same - // (to guarantee deterministic results) - denoms.sort( - (o1, o2) => - -Amounts.cmp(o1.value, o2.value) || - Amounts.cmp(o1.feeDeposit, o2.feeDeposit) || - strcmp(o1.denomPubHash, o2.denomPubHash), - ); - return [denoms, wfPerExchange]; - }); -} - -function makeAvailabilityKey( - exchangeBaseUrl: string, - denomPubHash: string, - maxAge: number, -): string { - return `${denomPubHash};${maxAge};${exchangeBaseUrl}`; -} - -/** - * Selection result. - */ -interface SelResult { - /** - * Map from an availability key - * to an array of contributions. - */ - [avKey: string]: { - exchangeBaseUrl: string; - denomPubHash: string; - maxAge: number; - contributions: AmountJson[]; - }; -} - -export function selectGreedy( - req: SelectPayCoinRequestNg, - candidateDenoms: AvailableDenom[], - wireFeesPerExchange: Record, - tally: CoinSelectionTally, -): SelResult | undefined { - const { wireFeeAmortization } = req; - const selectedDenom: SelResult = {}; - for (const aci of candidateDenoms) { - const contributions: AmountJson[] = []; - for (let i = 0; i < aci.numAvailable; i++) { - // Don't use this coin if depositing it is more expensive than - // the amount it would give the merchant. - if (Amounts.cmp(aci.feeDeposit, aci.value) > 0) { - continue; - } - - if (Amounts.isZero(tally.amountPayRemaining)) { - // We have spent enough! - break; - } - - tally = tallyFees( - tally, - wireFeesPerExchange, - wireFeeAmortization, - aci.exchangeBaseUrl, - aci.feeDeposit, - ); - - let coinSpend = Amounts.max( - Amounts.min(tally.amountPayRemaining, aci.value), - aci.feeDeposit, - ); - - tally.amountPayRemaining = Amounts.sub( - tally.amountPayRemaining, - coinSpend, - ).amount; - contributions.push(coinSpend); - } - - if (contributions.length) { - const avKey = makeAvailabilityKey( - aci.exchangeBaseUrl, - aci.denomPubHash, - aci.maxAge, - ); - let sd = selectedDenom[avKey]; - if (!sd) { - sd = { - contributions: [], - denomPubHash: aci.denomPubHash, - exchangeBaseUrl: aci.exchangeBaseUrl, - maxAge: aci.maxAge, - }; - } - sd.contributions.push(...contributions); - selectedDenom[avKey] = sd; - } - - if (Amounts.isZero(tally.amountPayRemaining)) { - return selectedDenom; - } - } - return undefined; -} - -export function selectForced( - req: SelectPayCoinRequestNg, - candidateDenoms: AvailableDenom[], -): SelResult | undefined { - const selectedDenom: SelResult = {}; - - const forcedSelection = req.forcedSelection; - checkLogicInvariant(!!forcedSelection); - - for (const forcedCoin of forcedSelection.coins) { - let found = false; - for (const aci of candidateDenoms) { - if (aci.numAvailable <= 0) { - continue; - } - if (Amounts.cmp(aci.value, forcedCoin.value) === 0) { - aci.numAvailable--; - const avKey = makeAvailabilityKey( - aci.exchangeBaseUrl, - aci.denomPubHash, - aci.maxAge, - ); - let sd = selectedDenom[avKey]; - if (!sd) { - sd = { - contributions: [], - denomPubHash: aci.denomPubHash, - exchangeBaseUrl: aci.exchangeBaseUrl, - maxAge: aci.maxAge, - }; - } - sd.contributions.push(Amounts.parseOrThrow(forcedCoin.value)); - selectedDenom[avKey] = sd; - found = true; - break; - } - } - if (!found) { - throw Error("can't find coin for forced coin selection"); - } - } - - return selectedDenom; -} - -/** - * Given a list of candidate coins, select coins to spend under the merchant's - * constraints. - * - * The prevPayCoins can be specified to "repair" a coin selection - * by adding additional coins, after a broken (e.g. double-spent) coin - * has been removed from the selection. - * - * This function is only exported for the sake of unit tests. - */ -export async function selectPayCoinsNew( - ws: InternalWalletState, - req: SelectPayCoinRequestNg, -): Promise { - const { - contractTermsAmount, - depositFeeLimit, - wireFeeLimit, - wireFeeAmortization, - } = req; - - const [candidateDenoms, wireFeesPerExchange] = await selectCandidates( - ws, - req, - ); - - // logger.trace(`candidate denoms: ${j2s(candidateDenoms)}`); - - const coinPubs: string[] = []; - const coinContributions: AmountJson[] = []; - const currency = contractTermsAmount.currency; - - let tally: CoinSelectionTally = { - amountPayRemaining: contractTermsAmount, - amountWireFeeLimitRemaining: wireFeeLimit, - amountDepositFeeLimitRemaining: depositFeeLimit, - customerDepositFees: Amounts.getZero(currency), - customerWireFees: Amounts.getZero(currency), - wireFeeCoveredForExchange: new Set(), - }; - - const prevPayCoins = req.prevPayCoins ?? []; - - // Look at existing pay coin selection and tally up - for (const prev of prevPayCoins) { - tally = tallyFees( - tally, - wireFeesPerExchange, - wireFeeAmortization, - prev.exchangeBaseUrl, - prev.feeDeposit, - ); - tally.amountPayRemaining = Amounts.sub( - tally.amountPayRemaining, - prev.contribution, - ).amount; - - coinPubs.push(prev.coinPub); - coinContributions.push(prev.contribution); - } - - let selectedDenom: SelResult | undefined; - if (req.forcedSelection) { - selectedDenom = selectForced(req, candidateDenoms); - } else { - // FIXME: Here, we should select coins in a smarter way. - // Instead of always spending the next-largest coin, - // we should try to find the smallest coin that covers the - // amount. - selectedDenom = selectGreedy( - req, - candidateDenoms, - wireFeesPerExchange, - tally, - ); - } - - if (!selectedDenom) { - return undefined; - } - - const finalSel = selectedDenom; - - logger.trace(`coin selection request ${j2s(req)}`); - logger.trace(`selected coins (via denoms) for payment: ${j2s(finalSel)}`); - - await ws.db - .mktx((x) => [x.coins, x.denominations]) - .runReadOnly(async (tx) => { - for (const dph of Object.keys(finalSel)) { - const selInfo = finalSel[dph]; - const numRequested = selInfo.contributions.length; - const query = [ - selInfo.exchangeBaseUrl, - selInfo.denomPubHash, - selInfo.maxAge, - CoinStatus.Fresh, - ]; - logger.info(`query: ${j2s(query)}`); - const coins = - await tx.coins.indexes.byExchangeDenomPubHashAndAgeAndStatus.getAll( - query, - numRequested, - ); - if (coins.length != numRequested) { - throw Error( - `coin selection failed (not available anymore, got only ${coins.length}/${numRequested})`, - ); - } - coinPubs.push(...coins.map((x) => x.coinPub)); - coinContributions.push(...selInfo.contributions); - } - }); - - return { - paymentAmount: contractTermsAmount, - coinContributions, - coinPubs, - customerDepositFees: tally.customerDepositFees, - customerWireFees: tally.customerWireFees, - }; -} - -export async function checkPaymentByProposalId( - ws: InternalWalletState, - proposalId: string, - sessionId?: string, -): Promise { - let proposal = await ws.db - .mktx((x) => [x.proposals]) - .runReadOnly(async (tx) => { - return tx.proposals.get(proposalId); - }); - if (!proposal) { - throw Error(`could not get proposal ${proposalId}`); - } - if (proposal.proposalStatus === ProposalStatus.Repurchase) { - const existingProposalId = proposal.repurchaseProposalId; - if (!existingProposalId) { - throw Error("invalid proposal state"); - } - logger.trace("using existing purchase for same product"); - proposal = await ws.db - .mktx((x) => [x.proposals]) - .runReadOnly(async (tx) => { - return tx.proposals.get(existingProposalId); - }); - if (!proposal) { - throw Error("existing proposal is in wrong state"); - } - } - const d = proposal.download; - if (!d) { - logger.error("bad proposal", proposal); - throw Error("proposal is in invalid state"); - } - const contractData = d.contractData; - const merchantSig = d.contractData.merchantSig; - if (!merchantSig) { - throw Error("BUG: proposal is in invalid state"); - } - - proposalId = proposal.proposalId; - - // First check if we already paid for it. - const purchase = await ws.db - .mktx((x) => [x.purchases]) - .runReadOnly(async (tx) => { - return tx.purchases.get(proposalId); - }); - - if (!purchase) { - // If not already paid, check if we could pay for it. - const res = await selectPayCoinsNew(ws, { - auditors: contractData.allowedAuditors, - exchanges: contractData.allowedExchanges, - contractTermsAmount: contractData.amount, - depositFeeLimit: contractData.maxDepositFee, - wireFeeAmortization: contractData.wireFeeAmortization ?? 1, - wireFeeLimit: contractData.maxWireFee, - prevPayCoins: [], - requiredMinimumAge: contractData.minimumAge, - wireMethod: contractData.wireMethod, - }); - - if (!res) { - logger.info("not allowing payment, insufficient coins"); - return { - status: PreparePayResultType.InsufficientBalance, - contractTerms: d.contractTermsRaw, - proposalId: proposal.proposalId, - noncePriv: proposal.noncePriv, - amountRaw: Amounts.stringify(d.contractData.amount), - }; - } - - const totalCost = await getTotalPaymentCost(ws, res); - logger.trace("costInfo", totalCost); - logger.trace("coinsForPayment", res); - - return { - status: PreparePayResultType.PaymentPossible, - contractTerms: d.contractTermsRaw, - proposalId: proposal.proposalId, - noncePriv: proposal.noncePriv, - amountEffective: Amounts.stringify(totalCost), - amountRaw: Amounts.stringify(res.paymentAmount), - contractTermsHash: d.contractData.contractTermsHash, - }; - } - - if (purchase.lastSessionId !== sessionId) { - logger.trace( - "automatically re-submitting payment with different session ID", - ); - await ws.db - .mktx((x) => [x.purchases]) - .runReadWrite(async (tx) => { - const p = await tx.purchases.get(proposalId); - if (!p) { - return; - } - p.lastSessionId = sessionId; - p.paymentSubmitPending = true; - await tx.purchases.put(p); - }); - const r = await processPurchasePay(ws, proposalId, { forceNow: true }); - if (r.type !== OperationAttemptResultType.Finished) { - // FIXME: This does not surface the original error - throw Error("submitting pay failed"); - } - return { - status: PreparePayResultType.AlreadyConfirmed, - contractTerms: purchase.download.contractTermsRaw, - contractTermsHash: purchase.download.contractData.contractTermsHash, - paid: true, - amountRaw: Amounts.stringify(purchase.download.contractData.amount), - amountEffective: Amounts.stringify(purchase.totalPayCost), - proposalId, - }; - } else if (!purchase.timestampFirstSuccessfulPay) { - return { - status: PreparePayResultType.AlreadyConfirmed, - contractTerms: purchase.download.contractTermsRaw, - contractTermsHash: purchase.download.contractData.contractTermsHash, - paid: false, - amountRaw: Amounts.stringify(purchase.download.contractData.amount), - amountEffective: Amounts.stringify(purchase.totalPayCost), - proposalId, - }; - } else { - const paid = !purchase.paymentSubmitPending; - return { - status: PreparePayResultType.AlreadyConfirmed, - contractTerms: purchase.download.contractTermsRaw, - contractTermsHash: purchase.download.contractData.contractTermsHash, - paid, - amountRaw: Amounts.stringify(purchase.download.contractData.amount), - amountEffective: Amounts.stringify(purchase.totalPayCost), - ...(paid ? { nextUrl: purchase.download.contractData.orderId } : {}), - proposalId, - }; - } -} - -export async function getContractTermsDetails( - ws: InternalWalletState, - proposalId: string, -): Promise { - const proposal = await ws.db - .mktx((x) => [x.proposals]) - .runReadOnly(async (tx) => { - return tx.proposals.get(proposalId); - }); - - if (!proposal) { - throw Error(`proposal with id ${proposalId} not found`); - } - - if (!proposal.download || !proposal.download.contractData) { - throw Error("proposal is in invalid state"); - } - - return proposal.download.contractData; -} - -/** - * Check if a payment for the given taler://pay/ URI is possible. - * - * If the payment is possible, the signature are already generated but not - * yet send to the merchant. - */ -export async function preparePayForUri( - ws: InternalWalletState, - talerPayUri: string, -): Promise { - const uriResult = parsePayUri(talerPayUri); - - if (!uriResult) { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_INVALID_TALER_PAY_URI, - { - talerPayUri, - }, - `invalid taler://pay URI (${talerPayUri})`, - ); - } - - let proposalId = await startDownloadProposal( - ws, - uriResult.merchantBaseUrl, - uriResult.orderId, - uriResult.sessionId, - uriResult.claimToken, - uriResult.noncePriv, - ); - - return checkPaymentByProposalId(ws, proposalId, uriResult.sessionId); -} - -/** - * Generate deposit permissions for a purchase. - * - * Accesses the database and the crypto worker. - */ -export async function generateDepositPermissions( - ws: InternalWalletState, - payCoinSel: PayCoinSelection, - contractData: WalletContractData, -): Promise { - const depositPermissions: CoinDepositPermission[] = []; - const coinWithDenom: Array<{ - coin: CoinRecord; - denom: DenominationRecord; - }> = []; - await ws.db - .mktx((x) => [x.coins, x.denominations]) - .runReadOnly(async (tx) => { - for (let i = 0; i < payCoinSel.coinPubs.length; i++) { - const coin = await tx.coins.get(payCoinSel.coinPubs[i]); - if (!coin) { - throw Error("can't pay, allocated coin not found anymore"); - } - const denom = await tx.denominations.get([ - coin.exchangeBaseUrl, - coin.denomPubHash, - ]); - if (!denom) { - throw Error( - "can't pay, denomination of allocated coin not found anymore", - ); - } - coinWithDenom.push({ coin, denom }); - } - }); - - for (let i = 0; i < payCoinSel.coinPubs.length; i++) { - const { coin, denom } = coinWithDenom[i]; - let wireInfoHash: string; - wireInfoHash = contractData.wireInfoHash; - logger.trace( - `signing deposit permission for coin with ageRestriction=${j2s( - coin.ageCommitmentProof, - )}`, - ); - const dp = await ws.cryptoApi.signDepositPermission({ - coinPriv: coin.coinPriv, - coinPub: coin.coinPub, - contractTermsHash: contractData.contractTermsHash, - denomPubHash: coin.denomPubHash, - denomKeyType: denom.denomPub.cipher, - denomSig: coin.denomSig, - exchangeBaseUrl: coin.exchangeBaseUrl, - feeDeposit: denom.fees.feeDeposit, - merchantPub: contractData.merchantPub, - refundDeadline: contractData.refundDeadline, - spendAmount: payCoinSel.coinContributions[i], - timestamp: contractData.timestamp, - wireInfoHash, - ageCommitmentProof: coin.ageCommitmentProof, - requiredMinimumAge: contractData.minimumAge, - }); - depositPermissions.push(dp); - } - return depositPermissions; -} - -/** - * Run the operation handler for a payment - * and return the result as a {@link ConfirmPayResult}. - */ -export async function runPayForConfirmPay( - ws: InternalWalletState, - proposalId: string, -): Promise { - const res = await processPurchasePay(ws, proposalId, { forceNow: true }); - switch (res.type) { - case OperationAttemptResultType.Finished: { - const purchase = await ws.db - .mktx((x) => [x.purchases]) - .runReadOnly(async (tx) => { - return tx.purchases.get(proposalId); - }); - if (!purchase?.download) { - throw Error("purchase record not available anymore"); - } - return { - type: ConfirmPayResultType.Done, - contractTerms: purchase.download.contractTermsRaw, - transactionId: makeEventId(TransactionType.Payment, proposalId), - }; - } - case OperationAttemptResultType.Error: { - // We hide transient errors from the caller. - const opRetry = await ws.db - .mktx((x) => [x.operationRetries]) - .runReadOnly(async (tx) => - tx.operationRetries.get(RetryTags.byPaymentProposalId(proposalId)), - ); - const maxRetry = 3; - const numRetry = opRetry?.retryInfo.retryCounter ?? 0; - if ( - res.errorDetail.code === - TalerErrorCode.WALLET_PAY_MERCHANT_SERVER_ERROR && - numRetry < maxRetry - ) { - // Pretend the operation is pending instead of reporting - // an error, but only up to maxRetry attempts. - await storeOperationPending( - ws, - RetryTags.byPaymentProposalId(proposalId), - ); - return { - type: ConfirmPayResultType.Pending, - lastError: opRetry?.lastError, - transactionId: makeEventId(TransactionType.Payment, proposalId), - }; - } else { - // FIXME: allocate error code! - await storeOperationError( - ws, - RetryTags.byPaymentProposalId(proposalId), - res.errorDetail, - ); - throw Error("payment failed"); - } - } - case OperationAttemptResultType.Pending: - await storeOperationPending(ws, `${PendingTaskType.Pay}:${proposalId}`); - return { - type: ConfirmPayResultType.Pending, - transactionId: makeEventId(TransactionType.Payment, proposalId), - lastError: undefined, - }; - case OperationAttemptResultType.Longpoll: - throw Error("unexpected processPurchasePay result (longpoll)"); - default: - assertUnreachable(res); - } -} - -/** - * Confirm payment for a proposal previously claimed by the wallet. - */ -export async function confirmPay( - ws: InternalWalletState, - proposalId: string, - sessionIdOverride?: string, - forcedCoinSel?: ForcedCoinSel, -): Promise { - logger.trace( - `executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`, - ); - const proposal = await ws.db - .mktx((x) => [x.proposals]) - .runReadOnly(async (tx) => { - return tx.proposals.get(proposalId); - }); - - if (!proposal) { - throw Error(`proposal with id ${proposalId} not found`); - } - - const d = proposal.download; - if (!d) { - throw Error("proposal is in invalid state"); - } - - const existingPurchase = await ws.db - .mktx((x) => [x.purchases]) - .runReadWrite(async (tx) => { - const purchase = await tx.purchases.get(proposalId); - if ( - purchase && - sessionIdOverride !== undefined && - sessionIdOverride != purchase.lastSessionId - ) { - logger.trace(`changing session ID to ${sessionIdOverride}`); - purchase.lastSessionId = sessionIdOverride; - purchase.paymentSubmitPending = true; - await tx.purchases.put(purchase); - } - return purchase; - }); - - if (existingPurchase) { - logger.trace("confirmPay: submitting payment for existing purchase"); - return runPayForConfirmPay(ws, proposalId); - } - - logger.trace("confirmPay: purchase record does not exist yet"); - - const contractData = d.contractData; - - let res: PayCoinSelection | undefined = undefined; - - res = await selectPayCoinsNew(ws, { - auditors: contractData.allowedAuditors, - exchanges: contractData.allowedExchanges, - wireMethod: contractData.wireMethod, - contractTermsAmount: contractData.amount, - depositFeeLimit: contractData.maxDepositFee, - wireFeeAmortization: contractData.wireFeeAmortization ?? 1, - wireFeeLimit: contractData.maxWireFee, - prevPayCoins: [], - requiredMinimumAge: contractData.minimumAge, - forcedSelection: forcedCoinSel, - }); - - logger.trace("coin selection result", res); - - if (!res) { - // Should not happen, since checkPay should be called first - // FIXME: Actually, this should be handled gracefully, - // and the status should be stored in the DB. - logger.warn("not confirming payment, insufficient coins"); - throw Error("insufficient balance"); - } - - const depositPermissions = await generateDepositPermissions( - ws, - res, - d.contractData, - ); - - await recordConfirmPay( - ws, - proposal, - res, - depositPermissions, - sessionIdOverride, - ); - - return runPayForConfirmPay(ws, proposalId); -} - -export async function processPurchasePay( - ws: InternalWalletState, - proposalId: string, - options: { - forceNow?: boolean; - } = {}, -): Promise { - const purchase = await ws.db - .mktx((x) => [x.purchases]) - .runReadOnly(async (tx) => { - return tx.purchases.get(proposalId); - }); - if (!purchase) { - return { - type: OperationAttemptResultType.Error, - errorDetail: { - // FIXME: allocate more specific error code - code: TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION, - hint: `trying to pay for purchase that is not in the database`, - proposalId: proposalId, - }, - }; - } - if (!purchase.paymentSubmitPending) { - OperationAttemptResult.finishedEmpty(); - } - logger.trace(`processing purchase pay ${proposalId}`); - - const sessionId = purchase.lastSessionId; - - logger.trace("paying with session ID", sessionId); - - if (!purchase.merchantPaySig) { - const payUrl = new URL( - `orders/${purchase.download.contractData.orderId}/pay`, - purchase.download.contractData.merchantBaseUrl, - ).href; - - let depositPermissions: CoinDepositPermission[]; - - if (purchase.coinDepositPermissions) { - depositPermissions = purchase.coinDepositPermissions; - } else { - // FIXME: also cache! - depositPermissions = await generateDepositPermissions( - ws, - purchase.payCoinSelection, - purchase.download.contractData, - ); - } - - const reqBody = { - coins: depositPermissions, - session_id: purchase.lastSessionId, - }; - - logger.trace( - "making pay request ... ", - JSON.stringify(reqBody, undefined, 2), - ); - - const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () => - ws.http.postJson(payUrl, reqBody, { - timeout: getPayRequestTimeout(purchase), - }), - ); - - logger.trace(`got resp ${JSON.stringify(resp)}`); - - if (resp.status >= 500 && resp.status <= 599) { - const errDetails = await readUnexpectedResponseDetails(resp); - return { - type: OperationAttemptResultType.Error, - errorDetail: makeErrorDetail( - TalerErrorCode.WALLET_PAY_MERCHANT_SERVER_ERROR, - { - requestError: errDetails, - }, - ), - }; - } - - if (resp.status === HttpStatusCode.BadRequest) { - const errDetails = await readUnexpectedResponseDetails(resp); - logger.warn("unexpected 400 response for /pay"); - logger.warn(j2s(errDetails)); - await ws.db - .mktx((x) => [x.purchases]) - .runReadWrite(async (tx) => { - const purch = await tx.purchases.get(proposalId); - if (!purch) { - return; - } - purch.payFrozen = true; - await tx.purchases.put(purch); - }); - throw makePendingOperationFailedError( - errDetails, - TransactionType.Payment, - proposalId, - ); - } - - if (resp.status === HttpStatusCode.Conflict) { - const err = await readTalerErrorResponse(resp); - if ( - err.code === - TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_INSUFFICIENT_FUNDS - ) { - // Do this in the background, as it might take some time - handleInsufficientFunds(ws, proposalId, err).catch(async (e) => { - console.log("handling insufficient funds failed"); - - await scheduleRetry(ws, RetryTags.forPay(purchase), { - code: TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION, - message: "unexpected exception", - hint: "unexpected exception", - details: { - exception: e.toString(), - }, - }); - }); - - return { - type: OperationAttemptResultType.Pending, - result: undefined, - }; - } - } - - const merchantResp = await readSuccessResponseJsonOrThrow( - resp, - codecForMerchantPayResponse(), - ); - - logger.trace("got success from pay URL", merchantResp); - - const merchantPub = purchase.download.contractData.merchantPub; - const { valid } = await ws.cryptoApi.isValidPaymentSignature({ - contractHash: purchase.download.contractData.contractTermsHash, - merchantPub, - sig: merchantResp.sig, - }); - - if (!valid) { - logger.error("merchant payment signature invalid"); - // FIXME: properly display error - throw Error("merchant payment signature invalid"); - } - - await storeFirstPaySuccess(ws, proposalId, sessionId, merchantResp.sig); - await unblockBackup(ws, proposalId); - } else { - const payAgainUrl = new URL( - `orders/${purchase.download.contractData.orderId}/paid`, - purchase.download.contractData.merchantBaseUrl, - ).href; - const reqBody = { - sig: purchase.merchantPaySig, - h_contract: purchase.download.contractData.contractTermsHash, - session_id: sessionId ?? "", - }; - const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () => - ws.http.postJson(payAgainUrl, reqBody), - ); - if (resp.status !== 204) { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, - getHttpResponseErrorDetails(resp), - "/paid failed", - ); - } - await storePayReplaySuccess(ws, proposalId, sessionId); - await unblockBackup(ws, proposalId); - } - - ws.notify({ - type: NotificationType.PayOperationSuccess, - proposalId: purchase.proposalId, - }); - - return OperationAttemptResult.finishedEmpty(); -} - -export async function refuseProposal( - ws: InternalWalletState, - proposalId: string, -): Promise { - const success = await ws.db - .mktx((x) => [x.proposals]) - .runReadWrite(async (tx) => { - const proposal = await tx.proposals.get(proposalId); - if (!proposal) { - logger.trace(`proposal ${proposalId} not found, won't refuse proposal`); - return false; - } - if (proposal.proposalStatus !== ProposalStatus.Proposed) { - return false; - } - proposal.proposalStatus = ProposalStatus.Refused; - await tx.proposals.put(proposal); - return true; - }); - if (success) { - ws.notify({ - type: NotificationType.ProposalRefused, - }); - } -} diff --git a/packages/taler-wallet-core/src/operations/peer-to-peer.ts b/packages/taler-wallet-core/src/operations/peer-to-peer.ts deleted file mode 100644 index d30cb294d..000000000 --- a/packages/taler-wallet-core/src/operations/peer-to-peer.ts +++ /dev/null @@ -1,848 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022 GNUnet e.V. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see - */ - -/** - * Imports. - */ -import { - AbsoluteTime, - AcceptPeerPullPaymentRequest, - AcceptPeerPullPaymentResponse, - AcceptPeerPushPaymentRequest, - AcceptPeerPushPaymentResponse, - AgeCommitmentProof, - AmountJson, - Amounts, - AmountString, - buildCodecForObject, - CheckPeerPullPaymentRequest, - CheckPeerPullPaymentResponse, - CheckPeerPushPaymentRequest, - CheckPeerPushPaymentResponse, - Codec, - codecForAmountString, - codecForAny, - codecForExchangeGetContractResponse, - constructPayPullUri, - constructPayPushUri, - ContractTermsUtil, - decodeCrock, - Duration, - eddsaGetPublic, - encodeCrock, - ExchangePurseDeposits, - ExchangePurseMergeRequest, - ExchangeReservePurseRequest, - getRandomBytes, - InitiatePeerPullPaymentRequest, - InitiatePeerPullPaymentResponse, - InitiatePeerPushPaymentRequest, - InitiatePeerPushPaymentResponse, - j2s, - Logger, - parsePayPullUri, - parsePayPushUri, - RefreshReason, - strcmp, - TalerProtocolTimestamp, - TransactionType, - UnblindedSignature, - WalletAccountMergeFlags, -} from "@gnu-taler/taler-util"; -import { - CoinStatus, - MergeReserveInfo, - WithdrawalGroupStatus, - WalletStoresV1, - WithdrawalRecordType, -} from "../db.js"; -import { InternalWalletState } from "../internal-wallet-state.js"; -import { readSuccessResponseJsonOrThrow } from "../util/http.js"; -import { checkDbInvariant } from "../util/invariants.js"; -import { GetReadOnlyAccess } from "../util/query.js"; -import { spendCoins } from "../wallet.js"; -import { updateExchangeFromUrl } from "./exchanges.js"; -import { makeEventId } from "./transactions.js"; -import { internalCreateWithdrawalGroup } from "./withdraw.js"; - -const logger = new Logger("operations/peer-to-peer.ts"); - -export interface PeerCoinSelection { - exchangeBaseUrl: string; - - /** - * Info of Coins that were selected. - */ - coins: { - coinPub: string; - coinPriv: string; - contribution: AmountString; - denomPubHash: string; - denomSig: UnblindedSignature; - ageCommitmentProof: AgeCommitmentProof | undefined; - }[]; - - /** - * How much of the deposit fees is the customer paying? - */ - depositFees: AmountJson; -} - -interface CoinInfo { - /** - * Public key of the coin. - */ - coinPub: string; - - coinPriv: string; - - /** - * Deposit fee for the coin. - */ - feeDeposit: AmountJson; - - value: AmountJson; - - denomPubHash: string; - - denomSig: UnblindedSignature; - - maxAge: number; - ageCommitmentProof?: AgeCommitmentProof; -} - -export async function selectPeerCoins( - ws: InternalWalletState, - tx: GetReadOnlyAccess<{ - exchanges: typeof WalletStoresV1.exchanges; - denominations: typeof WalletStoresV1.denominations; - coins: typeof WalletStoresV1.coins; - }>, - instructedAmount: AmountJson, -): Promise { - const exchanges = await tx.exchanges.iter().toArray(); - for (const exch of exchanges) { - if (exch.detailsPointer?.currency !== instructedAmount.currency) { - continue; - } - const coins = ( - await tx.coins.indexes.byBaseUrl.getAll(exch.baseUrl) - ).filter((x) => x.status === CoinStatus.Fresh); - const coinInfos: CoinInfo[] = []; - for (const coin of coins) { - const denom = await ws.getDenomInfo( - ws, - tx, - coin.exchangeBaseUrl, - coin.denomPubHash, - ); - if (!denom) { - throw Error("denom not found"); - } - coinInfos.push({ - coinPub: coin.coinPub, - feeDeposit: denom.feeDeposit, - value: denom.value, - denomPubHash: denom.denomPubHash, - coinPriv: coin.coinPriv, - denomSig: coin.denomSig, - maxAge: coin.maxAge, - ageCommitmentProof: coin.ageCommitmentProof, - }); - } - if (coinInfos.length === 0) { - continue; - } - coinInfos.sort( - (o1, o2) => - -Amounts.cmp(o1.value, o2.value) || - strcmp(o1.denomPubHash, o2.denomPubHash), - ); - let amountAcc = Amounts.getZero(instructedAmount.currency); - let depositFeesAcc = Amounts.getZero(instructedAmount.currency); - const resCoins: { - coinPub: string; - coinPriv: string; - contribution: AmountString; - denomPubHash: string; - denomSig: UnblindedSignature; - ageCommitmentProof: AgeCommitmentProof | undefined; - }[] = []; - for (const coin of coinInfos) { - if (Amounts.cmp(amountAcc, instructedAmount) >= 0) { - const res: PeerCoinSelection = { - exchangeBaseUrl: exch.baseUrl, - coins: resCoins, - depositFees: depositFeesAcc, - }; - return res; - } - const gap = Amounts.add( - coin.feeDeposit, - Amounts.sub(instructedAmount, amountAcc).amount, - ).amount; - const contrib = Amounts.min(gap, coin.value); - amountAcc = Amounts.add( - amountAcc, - Amounts.sub(contrib, coin.feeDeposit).amount, - ).amount; - depositFeesAcc = Amounts.add(depositFeesAcc, coin.feeDeposit).amount; - resCoins.push({ - coinPriv: coin.coinPriv, - coinPub: coin.coinPub, - contribution: Amounts.stringify(contrib), - denomPubHash: coin.denomPubHash, - denomSig: coin.denomSig, - ageCommitmentProof: coin.ageCommitmentProof, - }); - } - continue; - } - return undefined; -} - -export async function initiatePeerToPeerPush( - ws: InternalWalletState, - req: InitiatePeerPushPaymentRequest, -): Promise { - const instructedAmount = Amounts.parseOrThrow(req.amount); - - const pursePair = await ws.cryptoApi.createEddsaKeypair({}); - const mergePair = await ws.cryptoApi.createEddsaKeypair({}); - - const purseExpiration: TalerProtocolTimestamp = AbsoluteTime.toTimestamp( - AbsoluteTime.addDuration( - AbsoluteTime.now(), - Duration.fromSpec({ days: 2 }), - ), - ); - - const contractTerms = { - ...req.partialContractTerms, - purse_expiration: purseExpiration, - amount: req.amount, - }; - - const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms); - - const econtractResp = await ws.cryptoApi.encryptContractForMerge({ - contractTerms, - mergePriv: mergePair.priv, - pursePriv: pursePair.priv, - pursePub: pursePair.pub, - }); - - const coinSelRes: PeerCoinSelection | undefined = await ws.db - .mktx((x) => [ - x.exchanges, - x.coins, - x.coinAvailability, - x.denominations, - x.refreshGroups, - x.peerPullPaymentInitiations, - x.peerPushPaymentInitiations, - ]) - .runReadWrite(async (tx) => { - const sel = await selectPeerCoins(ws, tx, instructedAmount); - if (!sel) { - return undefined; - } - - await spendCoins(ws, tx, { - allocationId: `peer-push:${pursePair.pub}`, - coinPubs: sel.coins.map((x) => x.coinPub), - contributions: sel.coins.map((x) => - Amounts.parseOrThrow(x.contribution), - ), - refreshReason: RefreshReason.PayPeerPush, - }); - - await tx.peerPushPaymentInitiations.add({ - amount: Amounts.stringify(instructedAmount), - contractPriv: econtractResp.contractPriv, - contractTerms, - exchangeBaseUrl: sel.exchangeBaseUrl, - mergePriv: mergePair.priv, - mergePub: mergePair.pub, - // FIXME: only set this later! - purseCreated: true, - purseExpiration: purseExpiration, - pursePriv: pursePair.priv, - pursePub: pursePair.pub, - timestampCreated: TalerProtocolTimestamp.now(), - }); - - return sel; - }); - logger.info(`selected p2p coins (push): ${j2s(coinSelRes)}`); - - if (!coinSelRes) { - throw Error("insufficient balance"); - } - - const purseSigResp = await ws.cryptoApi.signPurseCreation({ - hContractTerms, - mergePub: mergePair.pub, - minAge: 0, - purseAmount: Amounts.stringify(instructedAmount), - purseExpiration, - pursePriv: pursePair.priv, - }); - - const depositSigsResp = await ws.cryptoApi.signPurseDeposits({ - exchangeBaseUrl: coinSelRes.exchangeBaseUrl, - pursePub: pursePair.pub, - coins: coinSelRes.coins, - }); - - const createPurseUrl = new URL( - `purses/${pursePair.pub}/create`, - coinSelRes.exchangeBaseUrl, - ); - - const httpResp = await ws.http.postJson(createPurseUrl.href, { - amount: Amounts.stringify(instructedAmount), - merge_pub: mergePair.pub, - purse_sig: purseSigResp.sig, - h_contract_terms: hContractTerms, - purse_expiration: purseExpiration, - deposits: depositSigsResp.deposits, - min_age: 0, - econtract: econtractResp.econtract, - }); - - const resp = await httpResp.json(); - - logger.info(`resp: ${j2s(resp)}`); - - if (httpResp.status !== 200) { - throw Error("got error response from exchange"); - } - - return { - contractPriv: econtractResp.contractPriv, - mergePriv: mergePair.priv, - pursePub: pursePair.pub, - exchangeBaseUrl: coinSelRes.exchangeBaseUrl, - talerUri: constructPayPushUri({ - exchangeBaseUrl: coinSelRes.exchangeBaseUrl, - contractPriv: econtractResp.contractPriv, - }), - transactionId: makeEventId(TransactionType.PeerPushDebit, pursePair.pub), - }; -} - -interface ExchangePurseStatus { - balance: AmountString; -} - -export const codecForExchangePurseStatus = (): Codec => - buildCodecForObject() - .property("balance", codecForAmountString()) - .build("ExchangePurseStatus"); - -export async function checkPeerPushPayment( - ws: InternalWalletState, - req: CheckPeerPushPaymentRequest, -): Promise { - // FIXME: Check if existing record exists! - - const uri = parsePayPushUri(req.talerUri); - - if (!uri) { - throw Error("got invalid taler://pay-push URI"); - } - - const exchangeBaseUrl = uri.exchangeBaseUrl; - - await updateExchangeFromUrl(ws, exchangeBaseUrl); - - const contractPriv = uri.contractPriv; - const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv))); - - const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl); - - const contractHttpResp = await ws.http.get(getContractUrl.href); - - const contractResp = await readSuccessResponseJsonOrThrow( - contractHttpResp, - codecForExchangeGetContractResponse(), - ); - - const pursePub = contractResp.purse_pub; - - const dec = await ws.cryptoApi.decryptContractForMerge({ - ciphertext: contractResp.econtract, - contractPriv: contractPriv, - pursePub: pursePub, - }); - - const getPurseUrl = new URL(`purses/${pursePub}/deposit`, exchangeBaseUrl); - - const purseHttpResp = await ws.http.get(getPurseUrl.href); - - const purseStatus = await readSuccessResponseJsonOrThrow( - purseHttpResp, - codecForExchangePurseStatus(), - ); - - const peerPushPaymentIncomingId = encodeCrock(getRandomBytes(32)); - - await ws.db - .mktx((x) => [x.peerPushPaymentIncoming]) - .runReadWrite(async (tx) => { - await tx.peerPushPaymentIncoming.add({ - peerPushPaymentIncomingId, - contractPriv: contractPriv, - exchangeBaseUrl: exchangeBaseUrl, - mergePriv: dec.mergePriv, - pursePub: pursePub, - timestamp: TalerProtocolTimestamp.now(), - contractTerms: dec.contractTerms, - }); - }); - - return { - amount: purseStatus.balance, - contractTerms: dec.contractTerms, - peerPushPaymentIncomingId, - }; -} - -export function talerPaytoFromExchangeReserve( - exchangeBaseUrl: string, - reservePub: string, -): string { - const url = new URL(exchangeBaseUrl); - let proto: string; - if (url.protocol === "http:") { - proto = "taler-reserve-http"; - } else if (url.protocol === "https:") { - proto = "taler-reserve"; - } else { - throw Error(`unsupported exchange base URL protocol (${url.protocol})`); - } - - let path = url.pathname; - if (!path.endsWith("/")) { - path = path + "/"; - } - - return `payto://${proto}/${url.host}${url.pathname}${reservePub}`; -} - -async function getMergeReserveInfo( - ws: InternalWalletState, - req: { - exchangeBaseUrl: string; - }, -): Promise { - // We have to eagerly create the key pair outside of the transaction, - // due to the async crypto API. - const newReservePair = await ws.cryptoApi.createEddsaKeypair({}); - - const mergeReserveInfo: MergeReserveInfo = await ws.db - .mktx((x) => [x.exchanges, x.withdrawalGroups]) - .runReadWrite(async (tx) => { - const ex = await tx.exchanges.get(req.exchangeBaseUrl); - checkDbInvariant(!!ex); - if (ex.currentMergeReserveInfo) { - return ex.currentMergeReserveInfo; - } - await tx.exchanges.put(ex); - ex.currentMergeReserveInfo = { - reservePriv: newReservePair.priv, - reservePub: newReservePair.pub, - }; - return ex.currentMergeReserveInfo; - }); - - return mergeReserveInfo; -} - -export async function acceptPeerPushPayment( - ws: InternalWalletState, - req: AcceptPeerPushPaymentRequest, -): Promise { - const peerInc = await ws.db - .mktx((x) => [x.peerPushPaymentIncoming]) - .runReadOnly(async (tx) => { - return tx.peerPushPaymentIncoming.get(req.peerPushPaymentIncomingId); - }); - - if (!peerInc) { - throw Error( - `can't accept unknown incoming p2p push payment (${req.peerPushPaymentIncomingId})`, - ); - } - - await updateExchangeFromUrl(ws, peerInc.exchangeBaseUrl); - - const amount = Amounts.parseOrThrow(peerInc.contractTerms.amount); - - const mergeReserveInfo = await getMergeReserveInfo(ws, { - exchangeBaseUrl: peerInc.exchangeBaseUrl, - }); - - const mergeTimestamp = TalerProtocolTimestamp.now(); - - const reservePayto = talerPaytoFromExchangeReserve( - peerInc.exchangeBaseUrl, - mergeReserveInfo.reservePub, - ); - - const sigRes = await ws.cryptoApi.signPurseMerge({ - contractTermsHash: ContractTermsUtil.hashContractTerms( - peerInc.contractTerms, - ), - flags: WalletAccountMergeFlags.MergeFullyPaidPurse, - mergePriv: peerInc.mergePriv, - mergeTimestamp: mergeTimestamp, - purseAmount: Amounts.stringify(amount), - purseExpiration: peerInc.contractTerms.purse_expiration, - purseFee: Amounts.stringify(Amounts.getZero(amount.currency)), - pursePub: peerInc.pursePub, - reservePayto, - reservePriv: mergeReserveInfo.reservePriv, - }); - - const mergePurseUrl = new URL( - `purses/${peerInc.pursePub}/merge`, - peerInc.exchangeBaseUrl, - ); - - const mergeReq: ExchangePurseMergeRequest = { - payto_uri: reservePayto, - merge_timestamp: mergeTimestamp, - merge_sig: sigRes.mergeSig, - reserve_sig: sigRes.accountSig, - }; - - const mergeHttpReq = await ws.http.postJson(mergePurseUrl.href, mergeReq); - - logger.info(`merge request: ${j2s(mergeReq)}`); - const res = await readSuccessResponseJsonOrThrow(mergeHttpReq, codecForAny()); - logger.info(`merge response: ${j2s(res)}`); - - const wg = await internalCreateWithdrawalGroup(ws, { - amount, - wgInfo: { - withdrawalType: WithdrawalRecordType.PeerPushCredit, - contractTerms: peerInc.contractTerms, - }, - exchangeBaseUrl: peerInc.exchangeBaseUrl, - reserveStatus: WithdrawalGroupStatus.QueryingStatus, - reserveKeyPair: { - priv: mergeReserveInfo.reservePriv, - pub: mergeReserveInfo.reservePub, - }, - }); - - return { - transactionId: makeEventId( - TransactionType.PeerPushCredit, - wg.withdrawalGroupId, - ), - }; -} - -/** - * FIXME: Bad name! - */ -export async function acceptPeerPullPayment( - ws: InternalWalletState, - req: AcceptPeerPullPaymentRequest, -): Promise { - const peerPullInc = await ws.db - .mktx((x) => [x.peerPullPaymentIncoming]) - .runReadOnly(async (tx) => { - return tx.peerPullPaymentIncoming.get(req.peerPullPaymentIncomingId); - }); - - if (!peerPullInc) { - throw Error( - `can't accept unknown incoming p2p pull payment (${req.peerPullPaymentIncomingId})`, - ); - } - - const instructedAmount = Amounts.parseOrThrow( - peerPullInc.contractTerms.amount, - ); - const coinSelRes: PeerCoinSelection | undefined = await ws.db - .mktx((x) => [ - x.exchanges, - x.coins, - x.denominations, - x.refreshGroups, - x.peerPullPaymentIncoming, - x.coinAvailability, - ]) - .runReadWrite(async (tx) => { - const sel = await selectPeerCoins(ws, tx, instructedAmount); - if (!sel) { - return undefined; - } - - await spendCoins(ws, tx, { - allocationId: `peer-pull:${req.peerPullPaymentIncomingId}`, - coinPubs: sel.coins.map((x) => x.coinPub), - contributions: sel.coins.map((x) => - Amounts.parseOrThrow(x.contribution), - ), - refreshReason: RefreshReason.PayPeerPull, - }); - - const pi = await tx.peerPullPaymentIncoming.get( - req.peerPullPaymentIncomingId, - ); - if (!pi) { - throw Error(); - } - pi.accepted = true; - await tx.peerPullPaymentIncoming.put(pi); - - return sel; - }); - logger.info(`selected p2p coins (pull): ${j2s(coinSelRes)}`); - - if (!coinSelRes) { - throw Error("insufficient balance"); - } - - const pursePub = peerPullInc.pursePub; - - const depositSigsResp = await ws.cryptoApi.signPurseDeposits({ - exchangeBaseUrl: coinSelRes.exchangeBaseUrl, - pursePub, - coins: coinSelRes.coins, - }); - - const purseDepositUrl = new URL( - `purses/${pursePub}/deposit`, - coinSelRes.exchangeBaseUrl, - ); - - const depositPayload: ExchangePurseDeposits = { - deposits: depositSigsResp.deposits, - }; - - const httpResp = await ws.http.postJson(purseDepositUrl.href, depositPayload); - const resp = await readSuccessResponseJsonOrThrow(httpResp, codecForAny()); - logger.trace(`purse deposit response: ${j2s(resp)}`); - - return { - transactionId: makeEventId( - TransactionType.PeerPullDebit, - req.peerPullPaymentIncomingId, - ), - }; -} - -export async function checkPeerPullPayment( - ws: InternalWalletState, - req: CheckPeerPullPaymentRequest, -): Promise { - const uri = parsePayPullUri(req.talerUri); - - if (!uri) { - throw Error("got invalid taler://pay-push URI"); - } - - const exchangeBaseUrl = uri.exchangeBaseUrl; - const contractPriv = uri.contractPriv; - const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv))); - - const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl); - - const contractHttpResp = await ws.http.get(getContractUrl.href); - - const contractResp = await readSuccessResponseJsonOrThrow( - contractHttpResp, - codecForExchangeGetContractResponse(), - ); - - const pursePub = contractResp.purse_pub; - - const dec = await ws.cryptoApi.decryptContractForDeposit({ - ciphertext: contractResp.econtract, - contractPriv: contractPriv, - pursePub: pursePub, - }); - - const getPurseUrl = new URL(`purses/${pursePub}/merge`, exchangeBaseUrl); - - const purseHttpResp = await ws.http.get(getPurseUrl.href); - - const purseStatus = await readSuccessResponseJsonOrThrow( - purseHttpResp, - codecForExchangePurseStatus(), - ); - - const peerPullPaymentIncomingId = encodeCrock(getRandomBytes(32)); - - await ws.db - .mktx((x) => [x.peerPullPaymentIncoming]) - .runReadWrite(async (tx) => { - await tx.peerPullPaymentIncoming.add({ - peerPullPaymentIncomingId, - contractPriv: contractPriv, - exchangeBaseUrl: exchangeBaseUrl, - pursePub: pursePub, - timestampCreated: TalerProtocolTimestamp.now(), - contractTerms: dec.contractTerms, - paid: false, - accepted: false, - }); - }); - - return { - amount: purseStatus.balance, - contractTerms: dec.contractTerms, - peerPullPaymentIncomingId, - }; -} - -/** - * Initiate a peer pull payment. - */ -export async function initiatePeerRequestForPay( - ws: InternalWalletState, - req: InitiatePeerPullPaymentRequest, -): Promise { - await updateExchangeFromUrl(ws, req.exchangeBaseUrl); - - const mergeReserveInfo = await getMergeReserveInfo(ws, { - exchangeBaseUrl: req.exchangeBaseUrl, - }); - - const mergeTimestamp = TalerProtocolTimestamp.now(); - - const pursePair = await ws.cryptoApi.createEddsaKeypair({}); - const mergePair = await ws.cryptoApi.createEddsaKeypair({}); - - const purseExpiration: TalerProtocolTimestamp = AbsoluteTime.toTimestamp( - AbsoluteTime.addDuration( - AbsoluteTime.now(), - Duration.fromSpec({ days: 2 }), - ), - ); - - const reservePayto = talerPaytoFromExchangeReserve( - req.exchangeBaseUrl, - mergeReserveInfo.reservePub, - ); - - const contractTerms = { - ...req.partialContractTerms, - amount: req.amount, - purse_expiration: purseExpiration, - }; - - const econtractResp = await ws.cryptoApi.encryptContractForDeposit({ - contractTerms, - pursePriv: pursePair.priv, - pursePub: pursePair.pub, - }); - - const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms); - - const purseFee = Amounts.stringify( - Amounts.getZero(Amounts.parseOrThrow(req.amount).currency), - ); - - const sigRes = await ws.cryptoApi.signReservePurseCreate({ - contractTermsHash: hContractTerms, - flags: WalletAccountMergeFlags.CreateWithPurseFee, - mergePriv: mergePair.priv, - mergeTimestamp: mergeTimestamp, - purseAmount: req.amount, - purseExpiration: purseExpiration, - purseFee: purseFee, - pursePriv: pursePair.priv, - pursePub: pursePair.pub, - reservePayto, - reservePriv: mergeReserveInfo.reservePriv, - }); - - await ws.db - .mktx((x) => [x.peerPullPaymentInitiations]) - .runReadWrite(async (tx) => { - await tx.peerPullPaymentInitiations.put({ - amount: req.amount, - contractTerms, - exchangeBaseUrl: req.exchangeBaseUrl, - pursePriv: pursePair.priv, - pursePub: pursePair.pub, - }); - }); - - const reservePurseReqBody: ExchangeReservePurseRequest = { - merge_sig: sigRes.mergeSig, - merge_timestamp: mergeTimestamp, - h_contract_terms: hContractTerms, - merge_pub: mergePair.pub, - min_age: 0, - purse_expiration: purseExpiration, - purse_fee: purseFee, - purse_pub: pursePair.pub, - purse_sig: sigRes.purseSig, - purse_value: req.amount, - reserve_sig: sigRes.accountSig, - econtract: econtractResp.econtract, - }; - - logger.info(`reserve purse request: ${j2s(reservePurseReqBody)}`); - - const reservePurseMergeUrl = new URL( - `reserves/${mergeReserveInfo.reservePub}/purse`, - req.exchangeBaseUrl, - ); - - const httpResp = await ws.http.postJson( - reservePurseMergeUrl.href, - reservePurseReqBody, - ); - - const resp = await readSuccessResponseJsonOrThrow(httpResp, codecForAny()); - - logger.info(`reserve merge response: ${j2s(resp)}`); - - const wg = await internalCreateWithdrawalGroup(ws, { - amount: Amounts.parseOrThrow(req.amount), - wgInfo: { - withdrawalType: WithdrawalRecordType.PeerPullCredit, - contractTerms, - contractPriv: econtractResp.contractPriv, - }, - exchangeBaseUrl: req.exchangeBaseUrl, - reserveStatus: WithdrawalGroupStatus.QueryingStatus, - reserveKeyPair: { - priv: mergeReserveInfo.reservePriv, - pub: mergeReserveInfo.reservePub, - }, - }); - - return { - talerUri: constructPayPullUri({ - exchangeBaseUrl: req.exchangeBaseUrl, - contractPriv: econtractResp.contractPriv, - }), - transactionId: makeEventId( - TransactionType.PeerPullCredit, - wg.withdrawalGroupId, - ), - }; -} diff --git a/packages/taler-wallet-core/src/operations/pending.ts b/packages/taler-wallet-core/src/operations/pending.ts index e4c270d85..db7a85432 100644 --- a/packages/taler-wallet-core/src/operations/pending.ts +++ b/packages/taler-wallet-core/src/operations/pending.ts @@ -23,7 +23,6 @@ */ import { ProposalStatus, - AbortStatus, WalletStoresV1, BackupProviderStateTag, RefreshCoinStatus, @@ -38,7 +37,6 @@ import { AbsoluteTime } from "@gnu-taler/taler-util"; import { InternalWalletState } from "../internal-wallet-state.js"; import { GetReadOnlyAccess } from "../util/query.js"; import { RetryTags } from "../util/retries.js"; -import { Wallet } from "../wallet.js"; import { GlobalIDB } from "@gnu-taler/idb-bridge"; function getPendingCommon( @@ -184,38 +182,6 @@ async function gatherWithdrawalPending( } } -async function gatherProposalPending( - ws: InternalWalletState, - tx: GetReadOnlyAccess<{ - proposals: typeof WalletStoresV1.proposals; - operationRetries: typeof WalletStoresV1.operationRetries; - }>, - now: AbsoluteTime, - resp: PendingOperationsResponse, -): Promise { - await tx.proposals.iter().forEachAsync(async (proposal) => { - if (proposal.proposalStatus == ProposalStatus.Proposed) { - // Nothing to do, user needs to choose. - } else if (proposal.proposalStatus == ProposalStatus.Downloading) { - const opId = RetryTags.forProposalClaim(proposal); - const retryRecord = await tx.operationRetries.get(opId); - const timestampDue = - retryRecord?.retryInfo?.nextRetry ?? AbsoluteTime.now(); - resp.pendingOperations.push({ - type: PendingTaskType.ProposalDownload, - ...getPendingCommon(ws, opId, timestampDue), - givesLifeness: true, - merchantBaseUrl: proposal.merchantBaseUrl, - orderId: proposal.orderId, - proposalId: proposal.proposalId, - proposalTimestamp: proposal.timestamp, - lastError: retryRecord?.lastError, - retryInfo: retryRecord?.retryInfo, - }); - } - }); -} - async function gatherDepositPending( ws: InternalWalletState, tx: GetReadOnlyAccess<{ @@ -287,44 +253,27 @@ async function gatherPurchasePending( resp: PendingOperationsResponse, ): Promise { // FIXME: Only iter purchases with some "active" flag! - await tx.purchases.iter().forEachAsync(async (pr) => { - if ( - pr.paymentSubmitPending && - pr.abortStatus === AbortStatus.None && - !pr.payFrozen - ) { - const payOpId = RetryTags.forPay(pr); - const payRetryRecord = await tx.operationRetries.get(payOpId); - - const timestampDue = - payRetryRecord?.retryInfo.nextRetry ?? AbsoluteTime.now(); - resp.pendingOperations.push({ - type: PendingTaskType.Pay, - ...getPendingCommon(ws, payOpId, timestampDue), - givesLifeness: true, - isReplay: false, - proposalId: pr.proposalId, - retryInfo: payRetryRecord?.retryInfo, - lastError: payRetryRecord?.lastError, - }); - } - if (pr.refundQueryRequested) { - const refundQueryOpId = RetryTags.forRefundQuery(pr); - const refundQueryRetryRecord = await tx.operationRetries.get( - refundQueryOpId, - ); + const keyRange = GlobalIDB.KeyRange.bound( + OperationStatusRange.ACTIVE_START, + OperationStatusRange.ACTIVE_END, + ); + await tx.purchases.indexes.byStatus + .iter(keyRange) + .forEachAsync(async (pr) => { + const opId = RetryTags.forPay(pr); + const retryRecord = await tx.operationRetries.get(opId); const timestampDue = - refundQueryRetryRecord?.retryInfo.nextRetry ?? AbsoluteTime.now(); + retryRecord?.retryInfo.nextRetry ?? AbsoluteTime.now(); resp.pendingOperations.push({ - type: PendingTaskType.RefundQuery, - ...getPendingCommon(ws, refundQueryOpId, timestampDue), + type: PendingTaskType.Purchase, + ...getPendingCommon(ws, opId, timestampDue), givesLifeness: true, + statusStr: ProposalStatus[pr.status], proposalId: pr.proposalId, - retryInfo: refundQueryRetryRecord?.retryInfo, - lastError: refundQueryRetryRecord?.lastError, + retryInfo: retryRecord?.retryInfo, + lastError: retryRecord?.lastError, }); - } - }); + }); } async function gatherRecoupPending( @@ -404,7 +353,6 @@ export async function getPendingOperations( x.refreshGroups, x.coins, x.withdrawalGroups, - x.proposals, x.tips, x.purchases, x.planchets, @@ -419,7 +367,6 @@ export async function getPendingOperations( await gatherExchangePending(ws, tx, now, resp); await gatherRefreshPending(ws, tx, now, resp); await gatherWithdrawalPending(ws, tx, now, resp); - await gatherProposalPending(ws, tx, now, resp); await gatherDepositPending(ws, tx, now, resp); await gatherTipPending(ws, tx, now, resp); await gatherPurchasePending(ws, tx, now, resp); diff --git a/packages/taler-wallet-core/src/operations/recoup.ts b/packages/taler-wallet-core/src/operations/recoup.ts index 2d92ff8ba..ff6bb4efc 100644 --- a/packages/taler-wallet-core/src/operations/recoup.ts +++ b/packages/taler-wallet-core/src/operations/recoup.ts @@ -27,16 +27,15 @@ import { Amounts, codecForRecoupConfirmation, + codecForReserveStatus, encodeCrock, getRandomBytes, j2s, Logger, NotificationType, RefreshReason, - TalerErrorDetail, TalerProtocolTimestamp, URL, - codecForReserveStatus, } from "@gnu-taler/taler-util"; import { CoinRecord, @@ -44,8 +43,8 @@ import { CoinStatus, RecoupGroupRecord, RefreshCoinSource, - WithdrawalGroupStatus, WalletStoresV1, + WithdrawalGroupStatus, WithdrawalRecordType, WithdrawCoinSource, } from "../db.js"; @@ -54,10 +53,8 @@ import { readSuccessResponseJsonOrThrow } from "../util/http.js"; import { GetReadWriteAccess } from "../util/query.js"; import { OperationAttemptResult, - RetryInfo, runOperationHandlerForResult, } from "../util/retries.js"; -import { guardOperationException } from "./common.js"; import { createRefreshGroup, processRefreshGroup } from "./refresh.js"; import { internalCreateWithdrawalGroup } from "./withdraw.js"; diff --git a/packages/taler-wallet-core/src/operations/refresh.ts b/packages/taler-wallet-core/src/operations/refresh.ts index 9fe2e6a8f..a5951ea53 100644 --- a/packages/taler-wallet-core/src/operations/refresh.ts +++ b/packages/taler-wallet-core/src/operations/refresh.ts @@ -78,7 +78,7 @@ import { OperationAttemptResult, OperationAttemptResultType, } from "../util/retries.js"; -import { makeCoinAvailable } from "../wallet.js"; +import { makeCoinAvailable } from "./common.js"; import { updateExchangeFromUrl } from "./exchanges.js"; import { isWithdrawableDenom, diff --git a/packages/taler-wallet-core/src/operations/refund.ts b/packages/taler-wallet-core/src/operations/refund.ts deleted file mode 100644 index 0d86b92ab..000000000 --- a/packages/taler-wallet-core/src/operations/refund.ts +++ /dev/null @@ -1,815 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019-2019 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 - */ - -/** - * Implementation of the refund operation. - * - * @author Florian Dold - */ - -/** - * Imports. - */ -import { - AbortingCoin, - AbortRequest, - AbsoluteTime, - AmountJson, - Amounts, - ApplyRefundResponse, - codecForAbortResponse, - codecForMerchantOrderRefundPickupResponse, - codecForMerchantOrderStatusPaid, - CoinPublicKey, - Duration, - Logger, - MerchantCoinRefundFailureStatus, - MerchantCoinRefundStatus, - MerchantCoinRefundSuccessStatus, - NotificationType, - parseRefundUri, - PrepareRefundResult, - RefreshReason, - TalerErrorCode, - TalerProtocolTimestamp, - TransactionType, - URL, -} from "@gnu-taler/taler-util"; -import { - AbortStatus, - CoinStatus, - DenominationRecord, - PurchaseRecord, - RefundReason, - RefundState, - WalletStoresV1, -} from "../db.js"; -import { InternalWalletState } from "../internal-wallet-state.js"; -import { readSuccessResponseJsonOrThrow } from "../util/http.js"; -import { checkDbInvariant } from "../util/invariants.js"; -import { GetReadWriteAccess } from "../util/query.js"; -import { OperationAttemptResult } from "../util/retries.js"; -import { createRefreshGroup, getTotalRefreshCost } from "./refresh.js"; -import { makeEventId } from "./transactions.js"; - -const logger = new Logger("refund.ts"); - -export async function prepareRefund( - ws: InternalWalletState, - talerRefundUri: string, -): Promise { - const parseResult = parseRefundUri(talerRefundUri); - - logger.trace("preparing refund offer", parseResult); - - if (!parseResult) { - throw Error("invalid refund URI"); - } - - const purchase = await ws.db - .mktx((x) => [x.purchases]) - .runReadOnly(async (tx) => { - return tx.purchases.indexes.byMerchantUrlAndOrderId.get([ - parseResult.merchantBaseUrl, - parseResult.orderId, - ]); - }); - - if (!purchase) { - throw Error( - `no purchase for the taler://refund/ URI (${talerRefundUri}) was found`, - ); - } - - const awaiting = await queryAndSaveAwaitingRefund(ws, purchase); - const summary = calculateRefundSummary(purchase); - const proposalId = purchase.proposalId; - - const { contractData: c } = purchase.download; - - return { - proposalId, - effectivePaid: Amounts.stringify(summary.amountEffectivePaid), - gone: Amounts.stringify(summary.amountRefundGone), - granted: Amounts.stringify(summary.amountRefundGranted), - pending: summary.pendingAtExchange, - awaiting: Amounts.stringify(awaiting), - info: { - contractTermsHash: c.contractTermsHash, - merchant: c.merchant, - orderId: c.orderId, - products: c.products, - summary: c.summary, - fulfillmentMessage: c.fulfillmentMessage, - summary_i18n: c.summaryI18n, - fulfillmentMessage_i18n: c.fulfillmentMessageI18n, - }, - }; -} - -function getRefundKey(d: MerchantCoinRefundStatus): string { - return `${d.coin_pub}-${d.rtransaction_id}`; -} - -async function applySuccessfulRefund( - tx: GetReadWriteAccess<{ - coins: typeof WalletStoresV1.coins; - denominations: typeof WalletStoresV1.denominations; - }>, - p: PurchaseRecord, - refreshCoinsMap: Record, - r: MerchantCoinRefundSuccessStatus, -): Promise { - // FIXME: check signature before storing it as valid! - - const refundKey = getRefundKey(r); - const coin = await tx.coins.get(r.coin_pub); - if (!coin) { - logger.warn("coin not found, can't apply refund"); - return; - } - const denom = await tx.denominations.get([ - coin.exchangeBaseUrl, - coin.denomPubHash, - ]); - if (!denom) { - throw Error("inconsistent database"); - } - refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub }; - const refundAmount = Amounts.parseOrThrow(r.refund_amount); - const refundFee = denom.fees.feeRefund; - coin.status = CoinStatus.Dormant; - coin.currentAmount = Amounts.add(coin.currentAmount, refundAmount).amount; - coin.currentAmount = Amounts.sub(coin.currentAmount, refundFee).amount; - logger.trace(`coin amount after is ${Amounts.stringify(coin.currentAmount)}`); - await tx.coins.put(coin); - - const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl - .iter(coin.exchangeBaseUrl) - .toArray(); - - const amountLeft = Amounts.sub( - Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount)) - .amount, - denom.fees.feeRefund, - ).amount; - - const totalRefreshCostBound = getTotalRefreshCost( - allDenoms, - DenominationRecord.toDenomInfo(denom), - amountLeft, - ); - - p.refunds[refundKey] = { - type: RefundState.Applied, - obtainedTime: AbsoluteTime.toTimestamp(AbsoluteTime.now()), - executionTime: r.execution_time, - refundAmount: Amounts.parseOrThrow(r.refund_amount), - refundFee: denom.fees.feeRefund, - totalRefreshCostBound, - coinPub: r.coin_pub, - rtransactionId: r.rtransaction_id, - }; -} - -async function storePendingRefund( - tx: GetReadWriteAccess<{ - denominations: typeof WalletStoresV1.denominations; - coins: typeof WalletStoresV1.coins; - }>, - p: PurchaseRecord, - r: MerchantCoinRefundFailureStatus, -): Promise { - const refundKey = getRefundKey(r); - - const coin = await tx.coins.get(r.coin_pub); - if (!coin) { - logger.warn("coin not found, can't apply refund"); - return; - } - const denom = await tx.denominations.get([ - coin.exchangeBaseUrl, - coin.denomPubHash, - ]); - - if (!denom) { - throw Error("inconsistent database"); - } - - const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl - .iter(coin.exchangeBaseUrl) - .toArray(); - - const amountLeft = Amounts.sub( - Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount)) - .amount, - denom.fees.feeRefund, - ).amount; - - const totalRefreshCostBound = getTotalRefreshCost( - allDenoms, - DenominationRecord.toDenomInfo(denom), - amountLeft, - ); - - p.refunds[refundKey] = { - type: RefundState.Pending, - obtainedTime: AbsoluteTime.toTimestamp(AbsoluteTime.now()), - executionTime: r.execution_time, - refundAmount: Amounts.parseOrThrow(r.refund_amount), - refundFee: denom.fees.feeRefund, - totalRefreshCostBound, - coinPub: r.coin_pub, - rtransactionId: r.rtransaction_id, - }; -} - -async function storeFailedRefund( - tx: GetReadWriteAccess<{ - coins: typeof WalletStoresV1.coins; - denominations: typeof WalletStoresV1.denominations; - }>, - p: PurchaseRecord, - refreshCoinsMap: Record, - r: MerchantCoinRefundFailureStatus, -): Promise { - const refundKey = getRefundKey(r); - - const coin = await tx.coins.get(r.coin_pub); - if (!coin) { - logger.warn("coin not found, can't apply refund"); - return; - } - const denom = await tx.denominations.get([ - coin.exchangeBaseUrl, - coin.denomPubHash, - ]); - - if (!denom) { - throw Error("inconsistent database"); - } - - const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl - .iter(coin.exchangeBaseUrl) - .toArray(); - - const amountLeft = Amounts.sub( - Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount)) - .amount, - denom.fees.feeRefund, - ).amount; - - const totalRefreshCostBound = getTotalRefreshCost( - allDenoms, - DenominationRecord.toDenomInfo(denom), - amountLeft, - ); - - p.refunds[refundKey] = { - type: RefundState.Failed, - obtainedTime: TalerProtocolTimestamp.now(), - executionTime: r.execution_time, - refundAmount: Amounts.parseOrThrow(r.refund_amount), - refundFee: denom.fees.feeRefund, - totalRefreshCostBound, - coinPub: r.coin_pub, - rtransactionId: r.rtransaction_id, - }; - - if (p.abortStatus === AbortStatus.AbortRefund) { - // Refund failed because the merchant didn't even try to deposit - // the coin yet, so we try to refresh. - if (r.exchange_code === TalerErrorCode.EXCHANGE_REFUND_DEPOSIT_NOT_FOUND) { - const coin = await tx.coins.get(r.coin_pub); - if (!coin) { - logger.warn("coin not found, can't apply refund"); - return; - } - const denom = await tx.denominations.get([ - coin.exchangeBaseUrl, - coin.denomPubHash, - ]); - if (!denom) { - logger.warn("denomination for coin missing"); - return; - } - let contrib: AmountJson | undefined; - for (let i = 0; i < p.payCoinSelection.coinPubs.length; i++) { - if (p.payCoinSelection.coinPubs[i] === r.coin_pub) { - contrib = p.payCoinSelection.coinContributions[i]; - } - } - if (contrib) { - coin.currentAmount = Amounts.add(coin.currentAmount, contrib).amount; - coin.currentAmount = Amounts.sub( - coin.currentAmount, - denom.fees.feeRefund, - ).amount; - } - refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub }; - await tx.coins.put(coin); - } - } -} - -async function acceptRefunds( - ws: InternalWalletState, - proposalId: string, - refunds: MerchantCoinRefundStatus[], - reason: RefundReason, -): Promise { - logger.trace("handling refunds", refunds); - const now = TalerProtocolTimestamp.now(); - - await ws.db - .mktx((x) => [ - x.purchases, - x.coins, - x.coinAvailability, - x.denominations, - x.refreshGroups, - ]) - .runReadWrite(async (tx) => { - const p = await tx.purchases.get(proposalId); - if (!p) { - logger.error("purchase not found, not adding refunds"); - return; - } - - const refreshCoinsMap: Record = {}; - - for (const refundStatus of refunds) { - const refundKey = getRefundKey(refundStatus); - const existingRefundInfo = p.refunds[refundKey]; - - const isPermanentFailure = - refundStatus.type === "failure" && - refundStatus.exchange_status >= 400 && - refundStatus.exchange_status < 500; - - // Already failed. - if (existingRefundInfo?.type === RefundState.Failed) { - continue; - } - - // Already applied. - if (existingRefundInfo?.type === RefundState.Applied) { - continue; - } - - // Still pending. - if ( - refundStatus.type === "failure" && - !isPermanentFailure && - existingRefundInfo?.type === RefundState.Pending - ) { - continue; - } - - // Invariant: (!existingRefundInfo) || (existingRefundInfo === Pending) - - if (refundStatus.type === "success") { - await applySuccessfulRefund(tx, p, refreshCoinsMap, refundStatus); - } else if (isPermanentFailure) { - await storeFailedRefund(tx, p, refreshCoinsMap, refundStatus); - } else { - await storePendingRefund(tx, p, refundStatus); - } - } - - const refreshCoinsPubs = Object.values(refreshCoinsMap); - if (refreshCoinsPubs.length > 0) { - await createRefreshGroup( - ws, - tx, - refreshCoinsPubs, - RefreshReason.Refund, - ); - } - - // Are we done with querying yet, or do we need to do another round - // after a retry delay? - let queryDone = true; - - if ( - p.timestampFirstSuccessfulPay && - p.autoRefundDeadline && - AbsoluteTime.cmp( - AbsoluteTime.fromTimestamp(p.autoRefundDeadline), - AbsoluteTime.fromTimestamp(now), - ) > 0 - ) { - queryDone = false; - } - - let numPendingRefunds = 0; - for (const ri of Object.values(p.refunds)) { - switch (ri.type) { - case RefundState.Pending: - numPendingRefunds++; - break; - } - } - - if (numPendingRefunds > 0) { - queryDone = false; - } - - if (queryDone) { - p.timestampLastRefundStatus = now; - p.refundQueryRequested = false; - if (p.abortStatus === AbortStatus.AbortRefund) { - p.abortStatus = AbortStatus.AbortFinished; - } - logger.trace("refund query done"); - } else { - // No error, but we need to try again! - p.timestampLastRefundStatus = now; - logger.trace("refund query not done"); - } - - await tx.purchases.put(p); - }); - - ws.notify({ - type: NotificationType.RefundQueried, - }); -} - -function calculateRefundSummary(p: PurchaseRecord): RefundSummary { - let amountRefundGranted = Amounts.getZero( - p.download.contractData.amount.currency, - ); - let amountRefundGone = Amounts.getZero( - p.download.contractData.amount.currency, - ); - - let pendingAtExchange = false; - - Object.keys(p.refunds).forEach((rk) => { - const refund = p.refunds[rk]; - if (refund.type === RefundState.Pending) { - pendingAtExchange = true; - } - if ( - refund.type === RefundState.Applied || - refund.type === RefundState.Pending - ) { - amountRefundGranted = Amounts.add( - amountRefundGranted, - Amounts.sub( - refund.refundAmount, - refund.refundFee, - refund.totalRefreshCostBound, - ).amount, - ).amount; - } else { - amountRefundGone = Amounts.add( - amountRefundGone, - refund.refundAmount, - ).amount; - } - }); - return { - amountEffectivePaid: p.totalPayCost, - amountRefundGone, - amountRefundGranted, - pendingAtExchange, - }; -} - -/** - * Summary of the refund status of a purchase. - */ -export interface RefundSummary { - pendingAtExchange: boolean; - amountEffectivePaid: AmountJson; - amountRefundGranted: AmountJson; - amountRefundGone: AmountJson; -} - -/** - * Accept a refund, return the contract hash for the contract - * that was involved in the refund. - */ -export async function applyRefund( - ws: InternalWalletState, - talerRefundUri: string, -): Promise { - const parseResult = parseRefundUri(talerRefundUri); - - logger.trace("applying refund", parseResult); - - if (!parseResult) { - throw Error("invalid refund URI"); - } - - const purchase = await ws.db - .mktx((x) => [x.purchases]) - .runReadOnly(async (tx) => { - return tx.purchases.indexes.byMerchantUrlAndOrderId.get([ - parseResult.merchantBaseUrl, - parseResult.orderId, - ]); - }); - - if (!purchase) { - throw Error( - `no purchase for the taler://refund/ URI (${talerRefundUri}) was found`, - ); - } - - return applyRefundFromPurchaseId(ws, purchase.proposalId); -} - -export async function applyRefundFromPurchaseId( - ws: InternalWalletState, - proposalId: string, -): Promise { - logger.trace("applying refund for purchase", proposalId); - - logger.info("processing purchase for refund"); - const success = await ws.db - .mktx((x) => [x.purchases]) - .runReadWrite(async (tx) => { - const p = await tx.purchases.get(proposalId); - if (!p) { - logger.error("no purchase found for refund URL"); - return false; - } - p.refundQueryRequested = true; - await tx.purchases.put(p); - return true; - }); - - if (success) { - ws.notify({ - type: NotificationType.RefundStarted, - }); - await processPurchaseQueryRefund(ws, proposalId, { - forceNow: true, - waitForAutoRefund: false, - }); - } - - const purchase = await ws.db - .mktx((x) => [x.purchases]) - .runReadOnly(async (tx) => { - return tx.purchases.get(proposalId); - }); - - if (!purchase) { - throw Error("purchase no longer exists"); - } - - const summary = calculateRefundSummary(purchase); - - return { - contractTermsHash: purchase.download.contractData.contractTermsHash, - proposalId: purchase.proposalId, - transactionId: makeEventId(TransactionType.Payment, proposalId), //FIXME: can we have the tx id of the refund - amountEffectivePaid: Amounts.stringify(summary.amountEffectivePaid), - amountRefundGone: Amounts.stringify(summary.amountRefundGone), - amountRefundGranted: Amounts.stringify(summary.amountRefundGranted), - pendingAtExchange: summary.pendingAtExchange, - info: { - contractTermsHash: purchase.download.contractData.contractTermsHash, - merchant: purchase.download.contractData.merchant, - orderId: purchase.download.contractData.orderId, - products: purchase.download.contractData.products, - summary: purchase.download.contractData.summary, - fulfillmentMessage: purchase.download.contractData.fulfillmentMessage, - summary_i18n: purchase.download.contractData.summaryI18n, - fulfillmentMessage_i18n: - purchase.download.contractData.fulfillmentMessageI18n, - }, - }; -} - -async function queryAndSaveAwaitingRefund( - ws: InternalWalletState, - purchase: PurchaseRecord, - waitForAutoRefund?: boolean, -): Promise { - const requestUrl = new URL( - `orders/${purchase.download.contractData.orderId}`, - purchase.download.contractData.merchantBaseUrl, - ); - requestUrl.searchParams.set( - "h_contract", - purchase.download.contractData.contractTermsHash, - ); - // Long-poll for one second - if (waitForAutoRefund) { - requestUrl.searchParams.set("timeout_ms", "1000"); - requestUrl.searchParams.set("await_refund_obtained", "yes"); - logger.trace("making long-polling request for auto-refund"); - } - const resp = await ws.http.get(requestUrl.href); - const orderStatus = await readSuccessResponseJsonOrThrow( - resp, - codecForMerchantOrderStatusPaid(), - ); - if (!orderStatus.refunded) { - // Wait for retry ... - return Amounts.getZero(purchase.totalPayCost.currency); - } - - const refundAwaiting = Amounts.sub( - Amounts.parseOrThrow(orderStatus.refund_amount), - Amounts.parseOrThrow(orderStatus.refund_taken), - ).amount; - - if ( - purchase.refundAwaiting === undefined || - Amounts.cmp(refundAwaiting, purchase.refundAwaiting) !== 0 - ) { - await ws.db - .mktx((x) => [x.purchases]) - .runReadWrite(async (tx) => { - const p = await tx.purchases.get(purchase.proposalId); - if (!p) { - logger.warn("purchase does not exist anymore"); - return; - } - p.refundAwaiting = refundAwaiting; - await tx.purchases.put(p); - }); - } - - return refundAwaiting; -} - -export async function processPurchaseQueryRefund( - ws: InternalWalletState, - proposalId: string, - options: { - forceNow?: boolean; - waitForAutoRefund?: boolean; - } = {}, -): Promise { - const waitForAutoRefund = options.waitForAutoRefund ?? false; - const purchase = await ws.db - .mktx((x) => [x.purchases]) - .runReadOnly(async (tx) => { - return tx.purchases.get(proposalId); - }); - if (!purchase) { - return OperationAttemptResult.finishedEmpty(); - } - - if (!purchase.refundQueryRequested) { - return OperationAttemptResult.finishedEmpty(); - } - - if (purchase.timestampFirstSuccessfulPay) { - if ( - !purchase.autoRefundDeadline || - !AbsoluteTime.isExpired( - AbsoluteTime.fromTimestamp(purchase.autoRefundDeadline), - ) - ) { - const awaitingAmount = await queryAndSaveAwaitingRefund( - ws, - purchase, - waitForAutoRefund, - ); - if (Amounts.isZero(awaitingAmount)) { - return OperationAttemptResult.finishedEmpty(); - } - } - - const requestUrl = new URL( - `orders/${purchase.download.contractData.orderId}/refund`, - purchase.download.contractData.merchantBaseUrl, - ); - - logger.trace(`making refund request to ${requestUrl.href}`); - - const request = await ws.http.postJson(requestUrl.href, { - h_contract: purchase.download.contractData.contractTermsHash, - }); - - const refundResponse = await readSuccessResponseJsonOrThrow( - request, - codecForMerchantOrderRefundPickupResponse(), - ); - - await acceptRefunds( - ws, - proposalId, - refundResponse.refunds, - RefundReason.NormalRefund, - ); - } else if (purchase.abortStatus === AbortStatus.AbortRefund) { - const requestUrl = new URL( - `orders/${purchase.download.contractData.orderId}/abort`, - purchase.download.contractData.merchantBaseUrl, - ); - - const abortingCoins: AbortingCoin[] = []; - - await ws.db - .mktx((x) => [x.coins]) - .runReadOnly(async (tx) => { - for (let i = 0; i < purchase.payCoinSelection.coinPubs.length; i++) { - const coinPub = purchase.payCoinSelection.coinPubs[i]; - const coin = await tx.coins.get(coinPub); - checkDbInvariant(!!coin, "expected coin to be present"); - abortingCoins.push({ - coin_pub: coinPub, - contribution: Amounts.stringify( - purchase.payCoinSelection.coinContributions[i], - ), - exchange_url: coin.exchangeBaseUrl, - }); - } - }); - - const abortReq: AbortRequest = { - h_contract: purchase.download.contractData.contractTermsHash, - coins: abortingCoins, - }; - - logger.trace(`making order abort request to ${requestUrl.href}`); - - const request = await ws.http.postJson(requestUrl.href, abortReq); - const abortResp = await readSuccessResponseJsonOrThrow( - request, - codecForAbortResponse(), - ); - - const refunds: MerchantCoinRefundStatus[] = []; - - if (abortResp.refunds.length != abortingCoins.length) { - // FIXME: define error code! - throw Error("invalid order abort response"); - } - - for (let i = 0; i < abortResp.refunds.length; i++) { - const r = abortResp.refunds[i]; - refunds.push({ - ...r, - coin_pub: purchase.payCoinSelection.coinPubs[i], - refund_amount: Amounts.stringify( - purchase.payCoinSelection.coinContributions[i], - ), - rtransaction_id: 0, - execution_time: AbsoluteTime.toTimestamp( - AbsoluteTime.addDuration( - AbsoluteTime.fromTimestamp( - purchase.download.contractData.timestamp, - ), - Duration.fromSpec({ seconds: 1 }), - ), - ), - }); - } - await acceptRefunds(ws, proposalId, refunds, RefundReason.AbortRefund); - } - return OperationAttemptResult.finishedEmpty(); -} - -export async function abortFailedPayWithRefund( - ws: InternalWalletState, - proposalId: string, -): Promise { - await ws.db - .mktx((x) => [x.purchases]) - .runReadWrite(async (tx) => { - const purchase = await tx.purchases.get(proposalId); - if (!purchase) { - throw Error("purchase not found"); - } - if (purchase.timestampFirstSuccessfulPay) { - // No point in aborting it. We don't even report an error. - logger.warn(`tried to abort successful payment`); - return; - } - if (purchase.abortStatus !== AbortStatus.None) { - return; - } - purchase.refundQueryRequested = true; - purchase.paymentSubmitPending = false; - purchase.abortStatus = AbortStatus.AbortRefund; - await tx.purchases.put(purchase); - }); - processPurchaseQueryRefund(ws, proposalId, { - forceNow: true, - }).catch((e) => { - logger.trace(`error during refund processing after abort pay: ${e}`); - }); -} diff --git a/packages/taler-wallet-core/src/operations/testing.ts b/packages/taler-wallet-core/src/operations/testing.ts index 598a88502..9a11af8bb 100644 --- a/packages/taler-wallet-core/src/operations/testing.ts +++ b/packages/taler-wallet-core/src/operations/testing.ts @@ -40,9 +40,8 @@ import { PreparePayResultType, } from "@gnu-taler/taler-util"; import { InternalWalletState } from "../internal-wallet-state.js"; -import { confirmPay, preparePayForUri } from "./pay.js"; +import { applyRefund, confirmPay, preparePayForUri } from "./pay-merchant.js"; import { getBalances } from "./balance.js"; -import { applyRefund } from "./refund.js"; import { checkLogicInvariant } from "../util/invariants.js"; import { acceptWithdrawalFromUri } from "./withdraw.js"; @@ -471,6 +470,6 @@ export async function testPay( }); checkLogicInvariant(!!purchase); return { - payCoinSelection: purchase.payCoinSelection, + payCoinSelection: purchase.payInfo?.payCoinSelection!, }; } diff --git a/packages/taler-wallet-core/src/operations/tip.ts b/packages/taler-wallet-core/src/operations/tip.ts index bd5ff51e7..a83867f55 100644 --- a/packages/taler-wallet-core/src/operations/tip.ts +++ b/packages/taler-wallet-core/src/operations/tip.ts @@ -18,8 +18,8 @@ * Imports. */ import { - AgeRestriction, AcceptTipResponse, + AgeRestriction, Amounts, BlindedDenominationSignature, codecForMerchantTipResponseV2, @@ -56,9 +56,8 @@ import { OperationAttemptResult, OperationAttemptResultType, } from "../util/retries.js"; -import { makeCoinAvailable } from "../wallet.js"; +import { makeCoinAvailable, makeEventId } from "./common.js"; import { updateExchangeFromUrl } from "./exchanges.js"; -import { makeEventId } from "./transactions.js"; import { getCandidateWithdrawalDenoms, getExchangeWithdrawalInfo, diff --git a/packages/taler-wallet-core/src/operations/transactions.ts b/packages/taler-wallet-core/src/operations/transactions.ts index 4086fc9b3..6ddf14f98 100644 --- a/packages/taler-wallet-core/src/operations/transactions.ts +++ b/packages/taler-wallet-core/src/operations/transactions.ts @@ -36,12 +36,12 @@ import { WithdrawalType, } from "@gnu-taler/taler-util"; import { - AbortStatus, DepositGroupRecord, ExchangeDetailsRecord, OperationRetryRecord, PeerPullPaymentIncomingRecord, PeerPushPaymentInitiationRecord, + ProposalStatus, PurchaseRecord, RefundState, TipRecord, @@ -50,10 +50,12 @@ import { WithdrawalRecordType, } from "../db.js"; import { InternalWalletState } from "../internal-wallet-state.js"; +import { checkDbInvariant } from "../util/invariants.js"; import { RetryTags } from "../util/retries.js"; +import { makeEventId, TombstoneTag } from "./common.js"; import { processDepositGroup } from "./deposits.js"; import { getExchangeDetails } from "./exchanges.js"; -import { processPurchasePay } from "./pay.js"; +import { expectProposalDownload, processPurchasePay } from "./pay-merchant.js"; import { processRefreshGroup } from "./refresh.js"; import { processTip } from "./tip.js"; import { @@ -63,28 +65,6 @@ import { const logger = new Logger("taler-wallet-core:transactions.ts"); -export enum TombstoneTag { - DeleteWithdrawalGroup = "delete-withdrawal-group", - DeleteReserve = "delete-reserve", - DeletePayment = "delete-payment", - DeleteTip = "delete-tip", - DeleteRefreshGroup = "delete-refresh-group", - DeleteDepositGroup = "delete-deposit-group", - DeleteRefund = "delete-refund", - DeletePeerPullDebit = "delete-peer-pull-debit", - DeletePeerPushDebit = "delete-peer-push-debit", -} - -/** - * Create an event ID from the type and the primary key for the event. - */ -export function makeEventId( - type: TransactionType | TombstoneTag, - ...args: string[] -): string { - return type + ":" + args.map((x) => encodeURIComponent(x)).join(":"); -} - function shouldSkipCurrency( transactionsRequest: TransactionsRequest | undefined, currency: string, @@ -219,29 +199,22 @@ export async function getTransactionById( }), ); + const download = await expectProposalDownload(purchase); + const cleanRefunds = filteredRefunds.filter( (x): x is WalletRefundItem => !!x, ); - const contractData = purchase.download.contractData; + const contractData = download.contractData; const refunds = mergeRefundByExecutionTime( cleanRefunds, Amounts.getZero(contractData.amount.currency), ); const payOpId = RetryTags.forPay(purchase); - const refundQueryOpId = RetryTags.forRefundQuery(purchase); const payRetryRecord = await tx.operationRetries.get(payOpId); - const refundQueryRetryRecord = await tx.operationRetries.get( - refundQueryOpId, - ); - - const err = - payRetryRecord !== undefined - ? payRetryRecord - : refundQueryRetryRecord; - return buildTransactionForPurchase(purchase, refunds, err); + return buildTransactionForPurchase(purchase, refunds, payRetryRecord); }); } else if (type === TransactionType.Refresh) { const refreshGroupId = rest[0]; @@ -295,23 +268,14 @@ export async function getTransactionById( ), ); if (t) throw Error("deleted"); - - const contractData = purchase.download.contractData; + const download = await expectProposalDownload(purchase); + const contractData = download.contractData; const refunds = mergeRefundByExecutionTime( [theRefund], Amounts.getZero(contractData.amount.currency), ); - const refundQueryOpId = RetryTags.forRefundQuery(purchase); - const refundQueryRetryRecord = await tx.operationRetries.get( - refundQueryOpId, - ); - - return buildTransactionForRefund( - purchase, - refunds[0], - refundQueryRetryRecord, - ); + return buildTransactionForRefund(purchase, refunds[0], undefined); }); } else if (type === TransactionType.PeerPullDebit) { const peerPullPaymentIncomingId = rest[0]; @@ -606,12 +570,13 @@ function mergeRefundByExecutionTime( return Array.from(refundByExecTime.values()); } -function buildTransactionForRefund( +async function buildTransactionForRefund( purchaseRecord: PurchaseRecord, refundInfo: MergedRefundInfo, ort?: OperationRetryRecord, -): Transaction { - const contractData = purchaseRecord.download.contractData; +): Promise { + const download = await expectProposalDownload(purchaseRecord); + const contractData = download.contractData; const info: OrderShortInfo = { merchant: contractData.merchant, @@ -641,21 +606,22 @@ function buildTransactionForRefund( amountEffective: Amounts.stringify(refundInfo.amountAppliedEffective), amountRaw: Amounts.stringify(refundInfo.amountAppliedRaw), refundPending: - purchaseRecord.refundAwaiting === undefined + purchaseRecord.refundAmountAwaiting === undefined ? undefined - : Amounts.stringify(purchaseRecord.refundAwaiting), + : Amounts.stringify(purchaseRecord.refundAmountAwaiting), pending: false, frozen: false, ...(ort?.lastError ? { error: ort.lastError } : {}), }; } -function buildTransactionForPurchase( +async function buildTransactionForPurchase( purchaseRecord: PurchaseRecord, refundsInfo: MergedRefundInfo[], ort?: OperationRetryRecord, -): Transaction { - const contractData = purchaseRecord.download.contractData; +): Promise { + const download = await expectProposalDownload(purchaseRecord); + const contractData = download.contractData; const zero = Amounts.getZero(contractData.amount.currency); const info: OrderShortInfo = { @@ -696,31 +662,34 @@ function buildTransactionForPurchase( ), })); + const timestamp = purchaseRecord.timestampAccept; + checkDbInvariant(!!timestamp); + checkDbInvariant(!!purchaseRecord.payInfo); + return { type: TransactionType.Payment, amountRaw: Amounts.stringify(contractData.amount), - amountEffective: Amounts.stringify(purchaseRecord.totalPayCost), + amountEffective: Amounts.stringify(purchaseRecord.payInfo.totalPayCost), totalRefundRaw: Amounts.stringify(totalRefund.raw), totalRefundEffective: Amounts.stringify(totalRefund.effective), refundPending: - purchaseRecord.refundAwaiting === undefined + purchaseRecord.refundAmountAwaiting === undefined ? undefined - : Amounts.stringify(purchaseRecord.refundAwaiting), + : Amounts.stringify(purchaseRecord.refundAmountAwaiting), status: purchaseRecord.timestampFirstSuccessfulPay ? PaymentStatus.Paid : PaymentStatus.Accepted, - pending: - !purchaseRecord.timestampFirstSuccessfulPay && - purchaseRecord.abortStatus === AbortStatus.None, + pending: purchaseRecord.status === ProposalStatus.Paying, refunds, - timestamp: purchaseRecord.timestampAccept, + timestamp, transactionId: makeEventId( TransactionType.Payment, purchaseRecord.proposalId, ), proposalId: purchaseRecord.proposalId, info, - frozen: purchaseRecord.payFrozen ?? false, + frozen: + purchaseRecord.status === ProposalStatus.PaymentAbortFinished ?? false, ...(ort?.lastError ? { error: ort.lastError } : {}), }; } @@ -745,7 +714,6 @@ export async function getTransactions( x.peerPullPaymentIncoming, x.peerPushPaymentInitiations, x.planchets, - x.proposals, x.purchases, x.recoupGroups, x.tips, @@ -838,30 +806,33 @@ export async function getTransactions( transactions.push(buildTransactionForDeposit(dg, retryRecord)); }); - tx.purchases.iter().forEachAsync(async (pr) => { + tx.purchases.iter().forEachAsync(async (purchase) => { + const download = purchase.download; + if (!download) { + return; + } + if (!purchase.payInfo) { + return; + } if ( shouldSkipCurrency( transactionsRequest, - pr.download.contractData.amount.currency, + download.contractData.amount.currency, ) ) { return; } - const contractData = pr.download.contractData; + const contractData = download.contractData; if (shouldSkipSearch(transactionsRequest, [contractData.summary])) { return; } - const proposal = await tx.proposals.get(pr.proposalId); - if (!proposal) { - return; - } const filteredRefunds = await Promise.all( - Object.values(pr.refunds).map(async (r) => { + Object.values(purchase.refunds).map(async (r) => { const t = await tx.tombstones.get( makeEventId( TombstoneTag.DeleteRefund, - pr.proposalId, + purchase.proposalId, `${r.executionTime.t_s}`, ), ); @@ -880,29 +851,16 @@ export async function getTransactions( ); refunds.forEach(async (refundInfo) => { - const refundQueryOpId = RetryTags.forRefundQuery(pr); - const refundQueryRetryRecord = await tx.operationRetries.get( - refundQueryOpId, - ); - transactions.push( - buildTransactionForRefund(pr, refundInfo, refundQueryRetryRecord), + await buildTransactionForRefund(purchase, refundInfo, undefined), ); }); - const payOpId = RetryTags.forPay(pr); - const refundQueryOpId = RetryTags.forRefundQuery(pr); + const payOpId = RetryTags.forPay(purchase); const payRetryRecord = await tx.operationRetries.get(payOpId); - const refundQueryRetryRecord = await tx.operationRetries.get( - refundQueryOpId, + transactions.push( + await buildTransactionForPurchase(purchase, refunds, payRetryRecord), ); - - const err = - payRetryRecord !== undefined - ? payRetryRecord - : refundQueryRetryRecord; - - transactions.push(buildTransactionForPurchase(pr, refunds, err)); }); tx.tips.iter().forEachAsync(async (tipRecord) => { @@ -1020,14 +978,9 @@ export async function deleteTransaction( } else if (type === TransactionType.Payment) { const proposalId = rest[0]; await ws.db - .mktx((x) => [x.proposals, x.purchases, x.tombstones]) + .mktx((x) => [x.purchases, x.tombstones]) .runReadWrite(async (tx) => { let found = false; - const proposal = await tx.proposals.get(proposalId); - if (proposal) { - found = true; - await tx.proposals.delete(proposalId); - } const purchase = await tx.purchases.get(proposalId); if (purchase) { found = true; @@ -1083,7 +1036,7 @@ export async function deleteTransaction( const executionTimeStr = rest[1]; await ws.db - .mktx((x) => [x.proposals, x.purchases, x.tombstones]) + .mktx((x) => [x.purchases, x.tombstones]) .runReadWrite(async (tx) => { const purchase = await tx.purchases.get(proposalId); if (purchase) { diff --git a/packages/taler-wallet-core/src/operations/withdraw.ts b/packages/taler-wallet-core/src/operations/withdraw.ts index fb5e2c70a..3c2541e9a 100644 --- a/packages/taler-wallet-core/src/operations/withdraw.ts +++ b/packages/taler-wallet-core/src/operations/withdraw.ts @@ -70,12 +70,11 @@ import { DenomSelectionState, ExchangeDetailsRecord, ExchangeRecord, - OperationStatus, PlanchetRecord, - WithdrawalGroupStatus, WalletStoresV1, WgInfo, WithdrawalGroupRecord, + WithdrawalGroupStatus, WithdrawalRecordType, } from "../db.js"; import { @@ -84,7 +83,10 @@ import { TalerError, } from "../errors.js"; import { InternalWalletState } from "../internal-wallet-state.js"; -import { assertUnreachable } from "../util/assertUnreachable.js"; +import { + makeCoinAvailable, + runOperationWithErrorReporting, +} from "../operations/common.js"; import { walletCoreDebugFlags } from "../util/debugFlags.js"; import { HttpRequestLibrary, @@ -108,18 +110,16 @@ import { WALLET_EXCHANGE_PROTOCOL_VERSION, } from "../versions.js"; import { - makeCoinAvailable, - runOperationWithErrorReporting, + makeEventId, storeOperationError, storeOperationPending, -} from "../wallet.js"; +} from "./common.js"; import { getExchangeDetails, getExchangePaytoUri, getExchangeTrust, updateExchangeFromUrl, } from "./exchanges.js"; -import { makeEventId } from "./transactions.js"; /** * Logger for this file. -- cgit v1.2.3