From ffd2a62c3f7df94365980302fef3bc3376b48182 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Mon, 3 Aug 2020 13:00:48 +0530 Subject: modularize repo, use pnpm, improve typechecking --- packages/taler-wallet-core/src/operations/pay.ts | 1148 ++++++++++++++++++++++ 1 file changed, 1148 insertions(+) create mode 100644 packages/taler-wallet-core/src/operations/pay.ts (limited to 'packages/taler-wallet-core/src/operations/pay.ts') diff --git a/packages/taler-wallet-core/src/operations/pay.ts b/packages/taler-wallet-core/src/operations/pay.ts new file mode 100644 index 000000000..f23e326f8 --- /dev/null +++ b/packages/taler-wallet-core/src/operations/pay.ts @@ -0,0 +1,1148 @@ +/* + This file is part of GNU Taler + (C) 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 payment operation, including downloading and + * claiming of proposals. + * + * @author Florian Dold + */ + +/** + * Imports. + */ +import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto"; +import { + CoinStatus, + initRetryInfo, + ProposalRecord, + ProposalStatus, + PurchaseRecord, + Stores, + updateRetryInfoTimeout, + PayEventRecord, + WalletContractData, +} from "../types/dbTypes"; +import { NotificationType } from "../types/notifications"; +import { + codecForProposal, + codecForContractTerms, + CoinDepositPermission, + codecForMerchantPayResponse, +} from "../types/talerTypes"; +import { + ConfirmPayResult, + OperationErrorDetails, + PreparePayResult, + RefreshReason, + PreparePayResultType, +} from "../types/walletTypes"; +import * as Amounts from "../util/amounts"; +import { AmountJson } from "../util/amounts"; +import { Logger } from "../util/logging"; +import { parsePayUri } from "../util/taleruri"; +import { guardOperationException, OperationFailedError } from "./errors"; +import { createRefreshGroup, getTotalRefreshCost } from "./refresh"; +import { InternalWalletState } from "./state"; +import { getTimestampNow, timestampAddDuration } from "../util/time"; +import { strcmp, canonicalJson } from "../util/helpers"; +import { readSuccessResponseJsonOrThrow } from "../util/http"; +import { TalerErrorCode } from "../TalerErrorCode"; +import { URL } from "../util/url"; + +/** + * Logger. + */ +const logger = new Logger("pay.ts"); + +/** + * Result of selecting coins, contains the exchange, and selected + * coins with their denomination. + */ +export interface PayCoinSelection { + /** + * Amount requested by the merchant. + */ + paymentAmount: AmountJson; + + /** + * Public keys of the coins that were selected. + */ + coinPubs: string[]; + + /** + * Amount that each coin contributes. + */ + coinContributions: AmountJson[]; + + /** + * How much of the wire fees is the customer paying? + */ + customerWireFees: AmountJson; + + /** + * How much of the deposit fees is the customer paying? + */ + customerDepositFees: AmountJson; +} + +/** + * Structure to describe a coin that is available to be + * used in a payment. + */ +export interface AvailableCoinInfo { + /** + * Public key of the coin. + */ + coinPub: string; + + /** + * Coin's denomination public key. + */ + denomPub: string; + + /** + * Amount still remaining (typically the full amount, + * as coins are always refreshed after use.) + */ + availableAmount: AmountJson; + + /** + * Deposit fee for the coin. + */ + feeDeposit: AmountJson; +} + +export interface PayCostInfo { + totalCost: AmountJson; +} + +/** + * 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 { + const costs = []; + for (let i = 0; i < pcs.coinPubs.length; i++) { + const coin = await ws.db.get(Stores.coins, pcs.coinPubs[i]); + if (!coin) { + throw Error("can't calculate payment cost, coin not found"); + } + const denom = await ws.db.get(Stores.denominations, [ + coin.exchangeBaseUrl, + coin.denomPub, + ]); + if (!denom) { + throw Error( + "can't calculate payment cost, denomination for coin not found", + ); + } + const allDenoms = await ws.db + .iterIndex( + Stores.denominations.exchangeBaseUrlIndex, + coin.exchangeBaseUrl, + ) + .toArray(); + const amountLeft = Amounts.sub(denom.value, pcs.coinContributions[i]) + .amount; + const refreshCost = getTotalRefreshCost(allDenoms, denom, amountLeft); + costs.push(pcs.coinContributions[i]); + costs.push(refreshCost); + } + return { + totalCost: Amounts.sum(costs).amount, + }; +} + +/** + * Given a list of available coins, select coins to spend under the merchant's + * constraints. + * + * This function is only exported for the sake of unit tests. + */ +export function selectPayCoins( + acis: AvailableCoinInfo[], + contractTermsAmount: AmountJson, + customerWireFees: AmountJson, + depositFeeLimit: AmountJson, +): PayCoinSelection | undefined { + if (acis.length === 0) { + return undefined; + } + const coinPubs: string[] = []; + const coinContributions: AmountJson[] = []; + // Sort by available amount (descending), deposit fee (ascending) and + // denomPub (ascending) if deposit fee is the same + // (to guarantee deterministic results) + acis.sort( + (o1, o2) => + -Amounts.cmp(o1.availableAmount, o2.availableAmount) || + Amounts.cmp(o1.feeDeposit, o2.feeDeposit) || + strcmp(o1.denomPub, o2.denomPub), + ); + const paymentAmount = Amounts.add(contractTermsAmount, customerWireFees) + .amount; + const currency = paymentAmount.currency; + let amountPayRemaining = paymentAmount; + let amountDepositFeeLimitRemaining = depositFeeLimit; + const customerDepositFees = Amounts.getZero(currency); + for (const aci of acis) { + // 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.availableAmount) >= 0) { + continue; + } + if (amountPayRemaining.value === 0 && amountPayRemaining.fraction === 0) { + // We have spent enough! + break; + } + + // How much does the user spend on deposit fees for this coin? + const depositFeeSpend = Amounts.sub( + aci.feeDeposit, + amountDepositFeeLimitRemaining, + ).amount; + + if (Amounts.isZero(depositFeeSpend)) { + // Fees are still covered by the merchant. + amountDepositFeeLimitRemaining = Amounts.sub( + amountDepositFeeLimitRemaining, + aci.feeDeposit, + ).amount; + } else { + amountDepositFeeLimitRemaining = Amounts.getZero(currency); + } + + let coinSpend: AmountJson; + const amountActualAvailable = Amounts.sub( + aci.availableAmount, + depositFeeSpend, + ).amount; + + if (Amounts.cmp(amountActualAvailable, amountPayRemaining) > 0) { + // Partial spending, as the coin is worth more than the remaining + // amount to pay. + coinSpend = Amounts.add(amountPayRemaining, depositFeeSpend).amount; + // Make sure we contribute at least the deposit fee, otherwise + // contributing this coin would cause a loss for the merchant. + if (Amounts.cmp(coinSpend, aci.feeDeposit) < 0) { + coinSpend = aci.feeDeposit; + } + amountPayRemaining = Amounts.getZero(currency); + } else { + // Spend the full remaining amount on the coin + coinSpend = aci.availableAmount; + amountPayRemaining = Amounts.add(amountPayRemaining, depositFeeSpend) + .amount; + amountPayRemaining = Amounts.sub(amountPayRemaining, aci.availableAmount) + .amount; + } + + coinPubs.push(aci.coinPub); + coinContributions.push(coinSpend); + } + if (Amounts.isZero(amountPayRemaining)) { + return { + paymentAmount: contractTermsAmount, + coinContributions, + coinPubs, + customerDepositFees, + customerWireFees, + }; + } + return undefined; +} + +/** + * Select coins from the wallet's database that can be used + * to pay for the given contract. + * + * If payment is impossible, undefined is returned. + */ +async function getCoinsForPayment( + ws: InternalWalletState, + contractData: WalletContractData, +): Promise { + const remainingAmount = contractData.amount; + + const exchanges = await ws.db.iter(Stores.exchanges).toArray(); + + for (const exchange of exchanges) { + let isOkay = false; + const exchangeDetails = exchange.details; + if (!exchangeDetails) { + continue; + } + const exchangeFees = exchange.wireInfo; + if (!exchangeFees) { + continue; + } + + // is the exchange explicitly allowed? + for (const allowedExchange of contractData.allowedExchanges) { + if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) { + isOkay = true; + break; + } + } + + // is the exchange allowed because of one of its auditors? + if (!isOkay) { + for (const allowedAuditor of contractData.allowedAuditors) { + for (const auditor of exchangeDetails.auditors) { + if (auditor.auditor_pub === allowedAuditor.auditorPub) { + isOkay = true; + break; + } + } + if (isOkay) { + break; + } + } + } + + if (!isOkay) { + continue; + } + + const coins = await ws.db + .iterIndex(Stores.coins.exchangeBaseUrlIndex, exchange.baseUrl) + .toArray(); + + if (!coins || coins.length === 0) { + continue; + } + + // Denomination of the first coin, we assume that all other + // coins have the same currency + const firstDenom = await ws.db.get(Stores.denominations, [ + exchange.baseUrl, + coins[0].denomPub, + ]); + if (!firstDenom) { + throw Error("db inconsistent"); + } + const currency = firstDenom.value.currency; + const acis: AvailableCoinInfo[] = []; + for (const coin of coins) { + const denom = await ws.db.get(Stores.denominations, [ + exchange.baseUrl, + coin.denomPub, + ]); + if (!denom) { + throw Error("db inconsistent"); + } + if (denom.value.currency !== currency) { + console.warn( + `same pubkey for different currencies at exchange ${exchange.baseUrl}`, + ); + continue; + } + if (coin.suspended) { + continue; + } + if (coin.status !== CoinStatus.Fresh) { + continue; + } + acis.push({ + availableAmount: coin.currentAmount, + coinPub: coin.coinPub, + denomPub: coin.denomPub, + feeDeposit: denom.feeDeposit, + }); + } + + let wireFee: AmountJson | undefined; + for (const fee of exchangeFees.feesForType[contractData.wireMethod] || []) { + if ( + fee.startStamp <= contractData.timestamp && + fee.endStamp >= contractData.timestamp + ) { + wireFee = fee.wireFee; + break; + } + } + + let customerWireFee: AmountJson; + + if (wireFee) { + const amortizedWireFee = Amounts.divide( + wireFee, + contractData.wireFeeAmortization, + ); + if (Amounts.cmp(contractData.maxWireFee, amortizedWireFee) < 0) { + customerWireFee = amortizedWireFee; + } else { + customerWireFee = Amounts.getZero(currency); + } + } else { + customerWireFee = Amounts.getZero(currency); + } + + // Try if paying using this exchange works + const res = selectPayCoins( + acis, + remainingAmount, + customerWireFee, + contractData.maxDepositFee, + ); + if (res) { + return res; + } + } + return undefined; +} + +/** + * 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 with session ID ${sessionId}`); + const payCostInfo = await getTotalPaymentCost(ws, coinSelection); + const t: PurchaseRecord = { + abortDone: false, + abortRequested: false, + contractTermsRaw: d.contractTermsRaw, + contractData: d.contractData, + lastSessionId: sessionId, + payCoinSelection: coinSelection, + payCostInfo, + coinDepositPermissions, + timestampAccept: getTimestampNow(), + timestampLastRefundStatus: undefined, + proposalId: proposal.proposalId, + lastPayError: undefined, + lastRefundStatusError: undefined, + payRetryInfo: initRetryInfo(), + refundStatusRetryInfo: initRetryInfo(), + refundStatusRequested: false, + timestampFirstSuccessfulPay: undefined, + autoRefundDeadline: undefined, + paymentSubmitPending: true, + refunds: {}, + }; + + await ws.db.runWithWriteTransaction( + [Stores.coins, Stores.purchases, Stores.proposals, Stores.refreshGroups], + async (tx) => { + const p = await tx.get(Stores.proposals, proposal.proposalId); + if (p) { + p.proposalStatus = ProposalStatus.ACCEPTED; + p.lastError = undefined; + p.retryInfo = initRetryInfo(false); + await tx.put(Stores.proposals, p); + } + await tx.put(Stores.purchases, t); + for (let i = 0; i < coinSelection.coinPubs.length; i++) { + const coin = await tx.get(Stores.coins, coinSelection.coinPubs[i]); + if (!coin) { + throw Error("coin allocated for payment doesn't exist anymore"); + } + coin.status = CoinStatus.Dormant; + const remaining = Amounts.sub( + coin.currentAmount, + coinSelection.coinContributions[i], + ); + if (remaining.saturated) { + throw Error("not enough remaining balance on coin for payment"); + } + coin.currentAmount = remaining.amount; + await tx.put(Stores.coins, coin); + } + const refreshCoinPubs = coinSelection.coinPubs.map((x) => ({ + coinPub: x, + })); + await createRefreshGroup(ws, tx, refreshCoinPubs, RefreshReason.Pay); + }, + ); + + ws.notify({ + type: NotificationType.ProposalAccepted, + proposalId: proposal.proposalId, + }); + return t; +} + +function getNextUrl(contractData: WalletContractData): string { + const f = contractData.fulfillmentUrl; + if (f.startsWith("http://") || f.startsWith("https://")) { + const fu = new URL(contractData.fulfillmentUrl); + fu.searchParams.set("order_id", contractData.orderId); + return fu.href; + } else { + return f; + } +} + +async function incrementProposalRetry( + ws: InternalWalletState, + proposalId: string, + err: OperationErrorDetails | undefined, +): Promise { + await ws.db.runWithWriteTransaction([Stores.proposals], async (tx) => { + const pr = await tx.get(Stores.proposals, proposalId); + if (!pr) { + return; + } + if (!pr.retryInfo) { + return; + } + pr.retryInfo.retryCounter++; + updateRetryInfoTimeout(pr.retryInfo); + pr.lastError = err; + await tx.put(Stores.proposals, pr); + }); + if (err) { + ws.notify({ type: NotificationType.ProposalOperationError, error: err }); + } +} + +async function incrementPurchasePayRetry( + ws: InternalWalletState, + proposalId: string, + err: OperationErrorDetails | undefined, +): Promise { + console.log("incrementing purchase pay retry with error", err); + await ws.db.runWithWriteTransaction([Stores.purchases], async (tx) => { + const pr = await tx.get(Stores.purchases, proposalId); + if (!pr) { + return; + } + if (!pr.payRetryInfo) { + return; + } + pr.payRetryInfo.retryCounter++; + updateRetryInfoTimeout(pr.payRetryInfo); + pr.lastPayError = err; + await tx.put(Stores.purchases, pr); + }); + if (err) { + ws.notify({ type: NotificationType.PayOperationError, error: err }); + } +} + +export async function processDownloadProposal( + ws: InternalWalletState, + proposalId: string, + forceNow = false, +): Promise { + const onOpErr = (err: OperationErrorDetails): Promise => + incrementProposalRetry(ws, proposalId, err); + await guardOperationException( + () => processDownloadProposalImpl(ws, proposalId, forceNow), + onOpErr, + ); +} + +async function resetDownloadProposalRetry( + ws: InternalWalletState, + proposalId: string, +): Promise { + await ws.db.mutate(Stores.proposals, proposalId, (x) => { + if (x.retryInfo.active) { + x.retryInfo = initRetryInfo(); + } + return x; + }); +} + +async function processDownloadProposalImpl( + ws: InternalWalletState, + proposalId: string, + forceNow: boolean, +): Promise { + if (forceNow) { + await resetDownloadProposalRetry(ws, proposalId); + } + const proposal = await ws.db.get(Stores.proposals, proposalId); + if (!proposal) { + return; + } + if (proposal.proposalStatus != ProposalStatus.DOWNLOADING) { + return; + } + + 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 resp = await ws.http.postJson(orderClaimUrl, requestBody); + const proposalResp = await readSuccessResponseJsonOrThrow( + resp, + codecForProposal(), + ); + + // 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. + + const contractTermsHash = await ws.cryptoApi.hashString( + canonicalJson(proposalResp.contract_terms), + ); + + const parsedContractTerms = codecForContractTerms().decode( + proposalResp.contract_terms, + ); + const fulfillmentUrl = parsedContractTerms.fulfillment_url; + + await ws.db.runWithWriteTransaction( + [Stores.proposals, Stores.purchases], + async (tx) => { + const p = await tx.get(Stores.proposals, proposalId); + if (!p) { + return; + } + if (p.proposalStatus !== ProposalStatus.DOWNLOADING) { + return; + } + 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); + } + p.download = { + contractData: { + amount, + contractTermsHash: contractTermsHash, + fulfillmentUrl: parsedContractTerms.fulfillment_url, + merchantBaseUrl: parsedContractTerms.merchant_base_url, + merchantPub: parsedContractTerms.merchant_pub, + merchantSig: proposalResp.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.master_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, + }, + contractTermsRaw: JSON.stringify(proposalResp.contract_terms), + }; + if ( + fulfillmentUrl.startsWith("http://") || + fulfillmentUrl.startsWith("https://") + ) { + const differentPurchase = await tx.getIndexed( + Stores.purchases.fulfillmentUrlIndex, + fulfillmentUrl, + ); + if (differentPurchase) { + console.log("repurchase detected"); + p.proposalStatus = ProposalStatus.REPURCHASE; + p.repurchaseProposalId = differentPurchase.proposalId; + await tx.put(Stores.proposals, p); + return; + } + } + p.proposalStatus = ProposalStatus.PROPOSED; + await tx.put(Stores.proposals, p); + }, + ); + + ws.notify({ + type: NotificationType.ProposalDownloaded, + proposalId: proposal.proposalId, + }); +} + +/** + * 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, +): Promise { + const oldProposal = await ws.db.getIndexed( + Stores.proposals.urlAndOrderIdIndex, + [merchantBaseUrl, orderId], + ); + if (oldProposal) { + await processDownloadProposal(ws, oldProposal.proposalId); + return oldProposal.proposalId; + } + + const { priv, pub } = await ws.cryptoApi.createEddsaKeypair(); + const proposalId = encodeCrock(getRandomBytes(32)); + + const proposalRecord: ProposalRecord = { + download: undefined, + noncePriv: priv, + noncePub: pub, + claimToken, + timestamp: getTimestampNow(), + merchantBaseUrl, + orderId, + proposalId: proposalId, + proposalStatus: ProposalStatus.DOWNLOADING, + repurchaseProposalId: undefined, + retryInfo: initRetryInfo(), + lastError: undefined, + downloadSessionId: sessionId, + }; + + await ws.db.runWithWriteTransaction([Stores.proposals], async (tx) => { + const existingRecord = await tx.getIndexed( + Stores.proposals.urlAndOrderIdIndex, + [merchantBaseUrl, orderId], + ); + if (existingRecord) { + // Created concurrently + return; + } + await tx.put(Stores.proposals, proposalRecord); + }); + + await processDownloadProposal(ws, proposalId); + return proposalId; +} + +export async function submitPay( + ws: InternalWalletState, + proposalId: string, +): Promise { + const purchase = await ws.db.get(Stores.purchases, proposalId); + if (!purchase) { + throw Error("Purchase not found: " + proposalId); + } + if (purchase.abortRequested) { + throw Error("not submitting payment for aborted purchase"); + } + const sessionId = purchase.lastSessionId; + + console.log("paying with session ID", sessionId); + + const payUrl = new URL( + `orders/${purchase.contractData.orderId}/pay`, + purchase.contractData.merchantBaseUrl, + ).href; + + const reqBody = { + coins: purchase.coinDepositPermissions, + session_id: purchase.lastSessionId, + }; + + logger.trace("making pay request", JSON.stringify(reqBody, undefined, 2)); + + const resp = await ws.http.postJson(payUrl, reqBody); + + const merchantResp = await readSuccessResponseJsonOrThrow( + resp, + codecForMerchantPayResponse(), + ); + + logger.trace("got success from pay URL", merchantResp); + + const now = getTimestampNow(); + + const merchantPub = purchase.contractData.merchantPub; + const valid: boolean = await ws.cryptoApi.isValidPaymentSignature( + merchantResp.sig, + purchase.contractData.contractTermsHash, + merchantPub, + ); + if (!valid) { + console.error("merchant payment signature invalid"); + // FIXME: properly display error + throw Error("merchant payment signature invalid"); + } + const isFirst = purchase.timestampFirstSuccessfulPay === undefined; + purchase.timestampFirstSuccessfulPay = now; + purchase.paymentSubmitPending = false; + purchase.lastPayError = undefined; + purchase.payRetryInfo = initRetryInfo(false); + if (isFirst) { + const ar = purchase.contractData.autoRefund; + if (ar) { + console.log("auto_refund present"); + purchase.refundStatusRequested = true; + purchase.refundStatusRetryInfo = initRetryInfo(); + purchase.lastRefundStatusError = undefined; + purchase.autoRefundDeadline = timestampAddDuration(now, ar); + } + } + + await ws.db.runWithWriteTransaction( + [Stores.purchases, Stores.payEvents], + async (tx) => { + await tx.put(Stores.purchases, purchase); + const payEvent: PayEventRecord = { + proposalId, + sessionId, + timestamp: now, + isReplay: !isFirst, + }; + await tx.put(Stores.payEvents, payEvent); + }, + ); + + const nextUrl = getNextUrl(purchase.contractData); + ws.cachedNextUrl[purchase.contractData.fulfillmentUrl] = { + nextUrl, + lastSessionId: sessionId, + }; + + return { nextUrl }; +} + +/** + * 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 OperationFailedError.fromCode( + TalerErrorCode.WALLET_INVALID_TALER_PAY_URI, + `invalid taler://pay URI (${talerPayUri})`, + { + talerPayUri, + }, + ); + } + + let proposalId = await startDownloadProposal( + ws, + uriResult.merchantBaseUrl, + uriResult.orderId, + uriResult.sessionId, + uriResult.claimToken, + ); + + let proposal = await ws.db.get(Stores.proposals, 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"); + } + console.log("using existing purchase for same product"); + proposal = await ws.db.get(Stores.proposals, existingProposalId); + if (!proposal) { + throw Error("existing proposal is in wrong state"); + } + } + const d = proposal.download; + if (!d) { + console.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 payed for it. + const purchase = await ws.db.get(Stores.purchases, proposalId); + + if (!purchase) { + // If not already paid, check if we could pay for it. + const res = await getCoinsForPayment(ws, contractData); + + if (!res) { + logger.info("not confirming payment, insufficient coins"); + return { + status: PreparePayResultType.InsufficientBalance, + contractTerms: JSON.parse(d.contractTermsRaw), + proposalId: proposal.proposalId, + }; + } + + const costInfo = await getTotalPaymentCost(ws, res); + logger.trace("costInfo", costInfo); + logger.trace("coinsForPayment", res); + + return { + status: PreparePayResultType.PaymentPossible, + contractTerms: JSON.parse(d.contractTermsRaw), + proposalId: proposal.proposalId, + amountEffective: Amounts.stringify(costInfo.totalCost), + amountRaw: Amounts.stringify(res.paymentAmount), + }; + } + + if (purchase.lastSessionId !== uriResult.sessionId) { + logger.trace( + "automatically re-submitting payment with different session ID", + ); + await ws.db.runWithWriteTransaction([Stores.purchases], async (tx) => { + const p = await tx.get(Stores.purchases, proposalId); + if (!p) { + return; + } + p.lastSessionId = uriResult.sessionId; + await tx.put(Stores.purchases, p); + }); + const r = await submitPay(ws, proposalId); + return { + status: PreparePayResultType.AlreadyConfirmed, + contractTerms: JSON.parse(purchase.contractTermsRaw), + paid: true, + nextUrl: r.nextUrl, + }; + } else if (!purchase.timestampFirstSuccessfulPay) { + return { + status: PreparePayResultType.AlreadyConfirmed, + contractTerms: JSON.parse(purchase.contractTermsRaw), + paid: false, + }; + } else if (purchase.paymentSubmitPending) { + return { + status: PreparePayResultType.AlreadyConfirmed, + contractTerms: JSON.parse(purchase.contractTermsRaw), + paid: false, + }; + } + // FIXME: we don't handle aborted payments correctly here. + throw Error("BUG: invariant violation (purchase status)"); +} + +/** + * Add a contract to the wallet and sign coins, and send them. + */ +export async function confirmPay( + ws: InternalWalletState, + proposalId: string, + sessionIdOverride: string | undefined, +): Promise { + logger.trace( + `executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`, + ); + const proposal = await ws.db.get(Stores.proposals, proposalId); + + if (!proposal) { + throw Error(`proposal with id ${proposalId} not found`); + } + + const d = proposal.download; + if (!d) { + throw Error("proposal is in invalid state"); + } + + let purchase = await ws.db.get( + Stores.purchases, + d.contractData.contractTermsHash, + ); + + if (purchase) { + if ( + sessionIdOverride !== undefined && + sessionIdOverride != purchase.lastSessionId + ) { + logger.trace(`changing session ID to ${sessionIdOverride}`); + await ws.db.mutate(Stores.purchases, purchase.proposalId, (x) => { + x.lastSessionId = sessionIdOverride; + x.paymentSubmitPending = true; + return x; + }); + } + logger.trace("confirmPay: submitting payment for existing purchase"); + return submitPay(ws, proposalId); + } + + logger.trace("confirmPay: purchase record does not exist yet"); + + const res = await getCoinsForPayment(ws, d.contractData); + + logger.trace("coin selection result", res); + + if (!res) { + // Should not happen, since checkPay should be called first + logger.warn("not confirming payment, insufficient coins"); + throw Error("insufficient balance"); + } + + const depositPermissions: CoinDepositPermission[] = []; + for (let i = 0; i < res.coinPubs.length; i++) { + const coin = await ws.db.get(Stores.coins, res.coinPubs[i]); + if (!coin) { + throw Error("can't pay, allocated coin not found anymore"); + } + const denom = await ws.db.get(Stores.denominations, [ + coin.exchangeBaseUrl, + coin.denomPub, + ]); + if (!denom) { + throw Error( + "can't pay, denomination of allocated coin not found anymore", + ); + } + const dp = await ws.cryptoApi.signDepositPermission({ + coinPriv: coin.coinPriv, + coinPub: coin.coinPub, + contractTermsHash: d.contractData.contractTermsHash, + denomPubHash: coin.denomPubHash, + denomSig: coin.denomSig, + exchangeBaseUrl: coin.exchangeBaseUrl, + feeDeposit: denom.feeDeposit, + merchantPub: d.contractData.merchantPub, + refundDeadline: d.contractData.refundDeadline, + spendAmount: res.coinContributions[i], + timestamp: d.contractData.timestamp, + wireInfoHash: d.contractData.wireInfoHash, + }); + depositPermissions.push(dp); + } + purchase = await recordConfirmPay( + ws, + proposal, + res, + depositPermissions, + sessionIdOverride, + ); + + return submitPay(ws, proposalId); +} + +export async function processPurchasePay( + ws: InternalWalletState, + proposalId: string, + forceNow = false, +): Promise { + const onOpErr = (e: OperationErrorDetails): Promise => + incrementPurchasePayRetry(ws, proposalId, e); + await guardOperationException( + () => processPurchasePayImpl(ws, proposalId, forceNow), + onOpErr, + ); +} + +async function resetPurchasePayRetry( + ws: InternalWalletState, + proposalId: string, +): Promise { + await ws.db.mutate(Stores.purchases, proposalId, (x) => { + if (x.payRetryInfo.active) { + x.payRetryInfo = initRetryInfo(); + } + return x; + }); +} + +async function processPurchasePayImpl( + ws: InternalWalletState, + proposalId: string, + forceNow: boolean, +): Promise { + if (forceNow) { + await resetPurchasePayRetry(ws, proposalId); + } + const purchase = await ws.db.get(Stores.purchases, proposalId); + if (!purchase) { + return; + } + if (!purchase.paymentSubmitPending) { + return; + } + logger.trace(`processing purchase pay ${proposalId}`); + await submitPay(ws, proposalId); +} + +export async function refuseProposal( + ws: InternalWalletState, + proposalId: string, +): Promise { + const success = await ws.db.runWithWriteTransaction( + [Stores.proposals], + async (tx) => { + const proposal = await tx.get(Stores.proposals, 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.put(Stores.proposals, proposal); + return true; + }, + ); + if (success) { + ws.notify({ + type: NotificationType.ProposalRefused, + }); + } +} -- cgit v1.2.3