diff options
Diffstat (limited to 'src/operations/pay.ts')
-rw-r--r-- | src/operations/pay.ts | 1494 |
1 files changed, 1494 insertions, 0 deletions
diff --git a/src/operations/pay.ts b/src/operations/pay.ts new file mode 100644 index 000000000..08d227927 --- /dev/null +++ b/src/operations/pay.ts @@ -0,0 +1,1494 @@ +/* + This file is part of GNU Taler + (C) 2019 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 <http://www.gnu.org/licenses/> + */ + +import { AmountJson } from "../util/amounts"; +import { + Auditor, + ExchangeHandle, + MerchantRefundResponse, + PayReq, + Proposal, + ContractTerms, + MerchantRefundPermission, + RefundRequest, +} from "../types/talerTypes"; +import { + Timestamp, + CoinSelectionResult, + CoinWithDenom, + PayCoinInfo, + getTimestampNow, + PreparePayResult, + ConfirmPayResult, + OperationError, +} from "../types/walletTypes"; +import { + oneShotIter, + oneShotIterIndex, + oneShotGet, + runWithWriteTransaction, + oneShotPut, + oneShotGetIndexed, + oneShotMutate, +} from "../util/query"; +import { + Stores, + CoinStatus, + DenominationRecord, + ProposalRecord, + PurchaseRecord, + CoinRecord, + ProposalStatus, + initRetryInfo, + updateRetryInfoTimeout, +} from "../types/dbTypes"; +import * as Amounts from "../util/amounts"; +import { + amountToPretty, + strcmp, + canonicalJson, + extractTalerStampOrThrow, + extractTalerDurationOrThrow, + extractTalerDuration, +} from "../util/helpers"; +import { Logger } from "../util/logging"; +import { InternalWalletState } from "./state"; +import { + parsePayUri, + parseRefundUri, + getOrderDownloadUrl, +} from "../util/taleruri"; +import { getTotalRefreshCost, refresh } from "./refresh"; +import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto"; +import { guardOperationException } from "./errors"; +import { assertUnreachable } from "../util/assertUnreachable"; +import { NotificationType } from "../types/notifications"; + +export interface SpeculativePayData { + payCoinInfo: PayCoinInfo; + exchangeUrl: string; + orderDownloadId: string; + proposal: ProposalRecord; +} + +interface CoinsForPaymentArgs { + allowedAuditors: Auditor[]; + allowedExchanges: ExchangeHandle[]; + depositFeeLimit: AmountJson; + paymentAmount: AmountJson; + wireFeeAmortization: number; + wireFeeLimit: AmountJson; + wireFeeTime: Timestamp; + wireMethod: string; +} + +interface SelectPayCoinsResult { + cds: CoinWithDenom[]; + totalFees: AmountJson; +} + +const logger = new Logger("pay.ts"); + +/** + * Select coins for a payment under the merchant's constraints. + * + * @param denoms all available denoms, used to compute refresh fees + */ +export function selectPayCoins( + denoms: DenominationRecord[], + cds: CoinWithDenom[], + paymentAmount: AmountJson, + depositFeeLimit: AmountJson, +): SelectPayCoinsResult | undefined { + if (cds.length === 0) { + return undefined; + } + // Sort by ascending deposit fee and denomPub if deposit fee is the same + // (to guarantee deterministic results) + cds.sort( + (o1, o2) => + Amounts.cmp(o1.denom.feeDeposit, o2.denom.feeDeposit) || + strcmp(o1.denom.denomPub, o2.denom.denomPub), + ); + const currency = cds[0].denom.value.currency; + const cdsResult: CoinWithDenom[] = []; + let accDepositFee: AmountJson = Amounts.getZero(currency); + let accAmount: AmountJson = Amounts.getZero(currency); + for (const { coin, denom } of cds) { + if (coin.suspended) { + continue; + } + if (coin.status !== CoinStatus.Fresh) { + continue; + } + if (Amounts.cmp(denom.feeDeposit, coin.currentAmount) >= 0) { + continue; + } + cdsResult.push({ coin, denom }); + accDepositFee = Amounts.add(denom.feeDeposit, accDepositFee).amount; + let leftAmount = Amounts.sub( + coin.currentAmount, + Amounts.sub(paymentAmount, accAmount).amount, + ).amount; + accAmount = Amounts.add(coin.currentAmount, accAmount).amount; + const coversAmount = Amounts.cmp(accAmount, paymentAmount) >= 0; + const coversAmountWithFee = + Amounts.cmp( + accAmount, + Amounts.add(paymentAmount, denom.feeDeposit).amount, + ) >= 0; + const isBelowFee = Amounts.cmp(accDepositFee, depositFeeLimit) <= 0; + + logger.trace("candidate coin selection", { + coversAmount, + isBelowFee, + accDepositFee, + accAmount, + paymentAmount, + }); + + if ((coversAmount && isBelowFee) || coversAmountWithFee) { + const depositFeeToCover = Amounts.sub(accDepositFee, depositFeeLimit) + .amount; + leftAmount = Amounts.sub(leftAmount, depositFeeToCover).amount; + logger.trace("deposit fee to cover", amountToPretty(depositFeeToCover)); + let totalFees: AmountJson = Amounts.getZero(currency); + if (coversAmountWithFee && !isBelowFee) { + // these are the fees the customer has to pay + // because the merchant doesn't cover them + totalFees = Amounts.sub(depositFeeLimit, accDepositFee).amount; + } + totalFees = Amounts.add( + totalFees, + getTotalRefreshCost(denoms, denom, leftAmount), + ).amount; + return { cds: cdsResult, totalFees }; + } + } + return undefined; +} + +/** + * Get exchanges and associated coins that are still spendable, but only + * if the sum the coins' remaining value covers the payment amount and fees. + */ +async function getCoinsForPayment( + ws: InternalWalletState, + args: CoinsForPaymentArgs, +): Promise<CoinSelectionResult | undefined> { + const { + allowedAuditors, + allowedExchanges, + depositFeeLimit, + paymentAmount, + wireFeeAmortization, + wireFeeLimit, + wireFeeTime, + wireMethod, + } = args; + + let remainingAmount = paymentAmount; + + const exchanges = await oneShotIter(ws.db, Stores.exchanges).toArray(); + + for (const exchange of exchanges) { + let isOkay: boolean = false; + const exchangeDetails = exchange.details; + if (!exchangeDetails) { + continue; + } + const exchangeFees = exchange.wireInfo; + if (!exchangeFees) { + continue; + } + + // is the exchange explicitly allowed? + for (const allowedExchange of allowedExchanges) { + if (allowedExchange.master_pub === exchangeDetails.masterPublicKey) { + isOkay = true; + break; + } + } + + // is the exchange allowed because of one of its auditors? + if (!isOkay) { + for (const allowedAuditor of allowedAuditors) { + for (const auditor of exchangeDetails.auditors) { + if (auditor.auditor_pub === allowedAuditor.auditor_pub) { + isOkay = true; + break; + } + } + if (isOkay) { + break; + } + } + } + + if (!isOkay) { + continue; + } + + const coins = await oneShotIterIndex( + ws.db, + Stores.coins.exchangeBaseUrlIndex, + exchange.baseUrl, + ).toArray(); + + const denoms = await oneShotIterIndex( + ws.db, + Stores.denominations.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 oneShotGet(ws.db, Stores.denominations, [ + exchange.baseUrl, + coins[0].denomPub, + ]); + if (!firstDenom) { + throw Error("db inconsistent"); + } + const currency = firstDenom.value.currency; + const cds: CoinWithDenom[] = []; + for (const coin of coins) { + const denom = await oneShotGet(ws.db, 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; + } + cds.push({ coin, denom }); + } + + let totalFees = Amounts.getZero(currency); + let wireFee: AmountJson | undefined; + for (const fee of exchangeFees.feesForType[wireMethod] || []) { + if (fee.startStamp <= wireFeeTime && fee.endStamp >= wireFeeTime) { + wireFee = fee.wireFee; + break; + } + } + + if (wireFee) { + const amortizedWireFee = Amounts.divide(wireFee, wireFeeAmortization); + if (Amounts.cmp(wireFeeLimit, amortizedWireFee) < 0) { + totalFees = Amounts.add(amortizedWireFee, totalFees).amount; + remainingAmount = Amounts.add(amortizedWireFee, remainingAmount).amount; + } + } + + const res = selectPayCoins(denoms, cds, remainingAmount, depositFeeLimit); + + if (res) { + totalFees = Amounts.add(totalFees, res.totalFees).amount; + return { + cds: res.cds, + exchangeUrl: exchange.baseUrl, + totalAmount: remainingAmount, + totalFees, + }; + } + } + 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, + payCoinInfo: PayCoinInfo, + chosenExchange: string, + sessionIdOverride: string | undefined, +): Promise<PurchaseRecord> { + 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 payReq: PayReq = { + coins: payCoinInfo.sigs, + merchant_pub: d.contractTerms.merchant_pub, + mode: "pay", + order_id: d.contractTerms.order_id, + }; + const t: PurchaseRecord = { + abortDone: false, + abortRequested: false, + contractTerms: d.contractTerms, + contractTermsHash: d.contractTermsHash, + lastSessionId: sessionId, + merchantSig: d.merchantSig, + payReq, + refundsDone: {}, + refundsPending: {}, + acceptTimestamp: getTimestampNow(), + lastRefundStatusTimestamp: undefined, + proposalId: proposal.proposalId, + lastPayError: undefined, + lastRefundStatusError: undefined, + payRetryInfo: initRetryInfo(), + refundStatusRetryInfo: initRetryInfo(), + refundStatusRequested: false, + lastRefundApplyError: undefined, + refundApplyRetryInfo: initRetryInfo(), + firstSuccessfulPayTimestamp: undefined, + autoRefundDeadline: undefined, + paymentSubmitPending: true, + }; + + await runWithWriteTransaction( + ws.db, + [Stores.coins, Stores.purchases, Stores.proposals], + 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 c of payCoinInfo.updatedCoins) { + await tx.put(Stores.coins, c); + } + }, + ); + + ws.notify({ + type: NotificationType.ProposalAccepted, + proposalId: proposal.proposalId, + }); + return t; +} + +function getNextUrl(contractTerms: ContractTerms): string { + const f = contractTerms.fulfillment_url; + if (f.startsWith("http://") || f.startsWith("https://")) { + const fu = new URL(contractTerms.fulfillment_url); + fu.searchParams.set("order_id", contractTerms.order_id); + return fu.href; + } else { + return f; + } +} + +export async function abortFailedPayment( + ws: InternalWalletState, + proposalId: string, +): Promise<void> { + const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId); + if (!purchase) { + throw Error("Purchase not found, unable to abort with refund"); + } + if (purchase.firstSuccessfulPayTimestamp) { + throw Error("Purchase already finished, not aborting"); + } + if (purchase.abortDone) { + console.warn("abort requested on already aborted purchase"); + return; + } + + purchase.abortRequested = true; + + // From now on, we can't retry payment anymore, + // so mark this in the DB in case the /pay abort + // does not complete on the first try. + await oneShotPut(ws.db, Stores.purchases, purchase); + + let resp; + + const abortReq = { ...purchase.payReq, mode: "abort-refund" }; + + const payUrl = new URL("pay", purchase.contractTerms.merchant_base_url).href; + + try { + resp = await ws.http.postJson(payUrl, abortReq); + } catch (e) { + // Gives the user the option to retry / abort and refresh + console.log("aborting payment failed", e); + throw e; + } + + if (resp.status !== 200) { + throw Error(`unexpected status for /pay (${resp.status})`); + } + + const refundResponse = MerchantRefundResponse.checked(await resp.json()); + await acceptRefundResponse(ws, purchase.proposalId, refundResponse); + + await runWithWriteTransaction(ws.db, [Stores.purchases], async tx => { + const p = await tx.get(Stores.purchases, proposalId); + if (!p) { + return; + } + p.abortDone = true; + await tx.put(Stores.purchases, p); + }); +} + +async function incrementProposalRetry( + ws: InternalWalletState, + proposalId: string, + err: OperationError | undefined, +): Promise<void> { + await runWithWriteTransaction(ws.db, [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); + }); + ws.notify({ type: NotificationType.ProposalOperationError }); +} + +async function incrementPurchasePayRetry( + ws: InternalWalletState, + proposalId: string, + err: OperationError | undefined, +): Promise<void> { + console.log("incrementing purchase pay retry with error", err); + await runWithWriteTransaction(ws.db, [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); + }); + ws.notify({ type: NotificationType.PayOperationError }); +} + +async function incrementPurchaseQueryRefundRetry( + ws: InternalWalletState, + proposalId: string, + err: OperationError | undefined, +): Promise<void> { + console.log("incrementing purchase refund query retry with error", err); + await runWithWriteTransaction(ws.db, [Stores.purchases], async tx => { + const pr = await tx.get(Stores.purchases, proposalId); + if (!pr) { + return; + } + if (!pr.refundStatusRetryInfo) { + return; + } + pr.refundStatusRetryInfo.retryCounter++; + updateRetryInfoTimeout(pr.refundStatusRetryInfo); + pr.lastRefundStatusError = err; + await tx.put(Stores.purchases, pr); + }); + ws.notify({ type: NotificationType.RefundStatusOperationError }); +} + +async function incrementPurchaseApplyRefundRetry( + ws: InternalWalletState, + proposalId: string, + err: OperationError | undefined, +): Promise<void> { + console.log("incrementing purchase refund apply retry with error", err); + await runWithWriteTransaction(ws.db, [Stores.purchases], async tx => { + const pr = await tx.get(Stores.purchases, proposalId); + if (!pr) { + return; + } + if (!pr.refundApplyRetryInfo) { + return; + } + pr.refundApplyRetryInfo.retryCounter++; + updateRetryInfoTimeout(pr.refundStatusRetryInfo); + pr.lastRefundApplyError = err; + await tx.put(Stores.purchases, pr); + }); + ws.notify({ type: NotificationType.RefundApplyOperationError }); +} + +export async function processDownloadProposal( + ws: InternalWalletState, + proposalId: string, + forceNow: boolean = false, +): Promise<void> { + const onOpErr = (err: OperationError) => + incrementProposalRetry(ws, proposalId, err); + await guardOperationException( + () => processDownloadProposalImpl(ws, proposalId, forceNow), + onOpErr, + ); +} + +async function resetDownloadProposalRetry( + ws: InternalWalletState, + proposalId: string, +) { + await oneShotMutate(ws.db, Stores.proposals, proposalId, x => { + if (x.retryInfo.active) { + x.retryInfo = initRetryInfo(); + } + return x; + }); +} + +async function processDownloadProposalImpl( + ws: InternalWalletState, + proposalId: string, + forceNow: boolean, +): Promise<void> { + if (forceNow) { + await resetDownloadProposalRetry(ws, proposalId); + } + const proposal = await oneShotGet(ws.db, Stores.proposals, proposalId); + if (!proposal) { + return; + } + if (proposal.proposalStatus != ProposalStatus.DOWNLOADING) { + return; + } + + const parsedUrl = new URL( + getOrderDownloadUrl(proposal.merchantBaseUrl, proposal.orderId), + ); + parsedUrl.searchParams.set("nonce", proposal.noncePub); + const urlWithNonce = parsedUrl.href; + console.log("downloading contract from '" + urlWithNonce + "'"); + let resp; + try { + resp = await ws.http.get(urlWithNonce); + } catch (e) { + console.log("contract download failed", e); + throw e; + } + + if (resp.status !== 200) { + throw Error(`contract download failed with status ${resp.status}`); + } + + const proposalResp = Proposal.checked(await resp.json()); + + const contractTermsHash = await ws.cryptoApi.hashString( + canonicalJson(proposalResp.contract_terms), + ); + + const fulfillmentUrl = proposalResp.contract_terms.fulfillment_url; + + await runWithWriteTransaction( + ws.db, + [Stores.proposals, Stores.purchases], + async tx => { + const p = await tx.get(Stores.proposals, proposalId); + if (!p) { + return; + } + if (p.proposalStatus !== ProposalStatus.DOWNLOADING) { + return; + } + 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.download = { + contractTerms: proposalResp.contract_terms, + merchantSig: proposalResp.sig, + contractTermsHash, + }; + 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, +): Promise<string> { + const oldProposal = await oneShotGetIndexed( + ws.db, + 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, + timestamp: getTimestampNow(), + merchantBaseUrl, + orderId, + proposalId: proposalId, + proposalStatus: ProposalStatus.DOWNLOADING, + repurchaseProposalId: undefined, + retryInfo: initRetryInfo(), + lastError: undefined, + downloadSessionId: sessionId, + }; + + await runWithWriteTransaction(ws.db, [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<ConfirmPayResult> { + const purchase = await oneShotGet(ws.db, 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; + let resp; + const payReq = { ...purchase.payReq, session_id: sessionId }; + + console.log("paying with session ID", sessionId); + + const payUrl = new URL("pay", purchase.contractTerms.merchant_base_url).href; + + try { + resp = await ws.http.postJson(payUrl, payReq); + } catch (e) { + // Gives the user the option to retry / abort and refresh + console.log("payment failed", e); + throw e; + } + if (resp.status !== 200) { + throw Error(`unexpected status (${resp.status}) for /pay`); + } + const merchantResp = await resp.json(); + console.log("got success from pay URL", merchantResp); + + const merchantPub = purchase.contractTerms.merchant_pub; + const valid: boolean = await ws.cryptoApi.isValidPaymentSignature( + merchantResp.sig, + purchase.contractTermsHash, + merchantPub, + ); + if (!valid) { + console.error("merchant payment signature invalid"); + // FIXME: properly display error + throw Error("merchant payment signature invalid"); + } + const isFirst = purchase.firstSuccessfulPayTimestamp === undefined; + purchase.firstSuccessfulPayTimestamp = getTimestampNow(); + purchase.paymentSubmitPending = false; + purchase.lastPayError = undefined; + purchase.payRetryInfo = initRetryInfo(false); + if (isFirst) { + const ar = purchase.contractTerms.auto_refund; + if (ar) { + console.log("auto_refund present"); + const autoRefundDelay = extractTalerDuration(ar); + console.log("auto_refund valid", autoRefundDelay); + if (autoRefundDelay) { + purchase.refundStatusRequested = true; + purchase.refundStatusRetryInfo = initRetryInfo(); + purchase.lastRefundStatusError = undefined; + purchase.autoRefundDeadline = { + t_ms: getTimestampNow().t_ms + autoRefundDelay.d_ms, + }; + } + } + } + + const modifiedCoins: CoinRecord[] = []; + for (const pc of purchase.payReq.coins) { + const c = await oneShotGet(ws.db, Stores.coins, pc.coin_pub); + if (!c) { + console.error("coin not found"); + throw Error("coin used in payment not found"); + } + c.status = CoinStatus.Dirty; + modifiedCoins.push(c); + } + + await runWithWriteTransaction( + ws.db, + [Stores.coins, Stores.purchases], + async tx => { + for (let c of modifiedCoins) { + await tx.put(Stores.coins, c); + } + await tx.put(Stores.purchases, purchase); + }, + ); + + for (const c of purchase.payReq.coins) { + refresh(ws, c.coin_pub).catch(e => { + console.log("error in refreshing after payment:", e); + }); + } + + const nextUrl = getNextUrl(purchase.contractTerms); + ws.cachedNextUrl[purchase.contractTerms.fulfillment_url] = { + 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 preparePay( + ws: InternalWalletState, + talerPayUri: string, +): Promise<PreparePayResult> { + const uriResult = parsePayUri(talerPayUri); + + if (!uriResult) { + return { + status: "error", + error: "URI not supported", + }; + } + + let proposalId = await startDownloadProposal( + ws, + uriResult.merchantBaseUrl, + uriResult.orderId, + uriResult.sessionId, + ); + + let proposal = await oneShotGet(ws.db, 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 oneShotGet(ws.db, 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 contractTerms = d.contractTerms; + const merchantSig = d.merchantSig; + if (!contractTerms || !merchantSig) { + throw Error("BUG: proposal is in invalid state"); + } + + proposalId = proposal.proposalId; + + // First check if we already payed for it. + const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId); + + if (!purchase) { + const paymentAmount = Amounts.parseOrThrow(contractTerms.amount); + let wireFeeLimit; + if (contractTerms.max_wire_fee) { + wireFeeLimit = Amounts.parseOrThrow(contractTerms.max_wire_fee); + } else { + wireFeeLimit = Amounts.getZero(paymentAmount.currency); + } + // If not already payed, check if we could pay for it. + const res = await getCoinsForPayment(ws, { + allowedAuditors: contractTerms.auditors, + allowedExchanges: contractTerms.exchanges, + depositFeeLimit: Amounts.parseOrThrow(contractTerms.max_fee), + paymentAmount, + wireFeeAmortization: contractTerms.wire_fee_amortization || 1, + wireFeeLimit, + wireFeeTime: extractTalerStampOrThrow(contractTerms.timestamp), + wireMethod: contractTerms.wire_method, + }); + + if (!res) { + console.log("not confirming payment, insufficient coins"); + return { + status: "insufficient-balance", + contractTerms: contractTerms, + proposalId: proposal.proposalId, + }; + } + + // Only create speculative signature if we don't already have one for this proposal + if ( + !ws.speculativePayData || + (ws.speculativePayData && + ws.speculativePayData.orderDownloadId !== proposalId) + ) { + const { exchangeUrl, cds, totalAmount } = res; + const payCoinInfo = await ws.cryptoApi.signDeposit( + contractTerms, + cds, + totalAmount, + ); + ws.speculativePayData = { + exchangeUrl, + payCoinInfo, + proposal, + orderDownloadId: proposalId, + }; + logger.trace("created speculative pay data for payment"); + } + + return { + status: "payment-possible", + contractTerms: contractTerms, + proposalId: proposal.proposalId, + totalFees: res.totalFees, + }; + } + + if (uriResult.sessionId) { + await submitPay(ws, proposalId); + } + + return { + status: "paid", + contractTerms: purchase.contractTerms, + nextUrl: getNextUrl(purchase.contractTerms), + }; +} + +/** + * Get the speculative pay data, but only if coins have not changed in between. + */ +async function getSpeculativePayData( + ws: InternalWalletState, + proposalId: string, +): Promise<SpeculativePayData | undefined> { + const sp = ws.speculativePayData; + if (!sp) { + return; + } + if (sp.orderDownloadId !== proposalId) { + return; + } + const coinKeys = sp.payCoinInfo.updatedCoins.map(x => x.coinPub); + const coins: CoinRecord[] = []; + for (let coinKey of coinKeys) { + const cc = await oneShotGet(ws.db, Stores.coins, coinKey); + if (cc) { + coins.push(cc); + } + } + for (let i = 0; i < coins.length; i++) { + const specCoin = sp.payCoinInfo.originalCoins[i]; + const currentCoin = coins[i]; + + // Coin does not exist anymore! + if (!currentCoin) { + return; + } + if (Amounts.cmp(specCoin.currentAmount, currentCoin.currentAmount) !== 0) { + return; + } + } + return sp; +} + +/** + * Add a contract to the wallet and sign coins, and send them. + */ +export async function confirmPay( + ws: InternalWalletState, + proposalId: string, + sessionIdOverride: string | undefined, +): Promise<ConfirmPayResult> { + logger.trace( + `executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`, + ); + const proposal = await oneShotGet(ws.db, 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 oneShotGet(ws.db, Stores.purchases, d.contractTermsHash); + + if (purchase) { + if ( + sessionIdOverride !== undefined && + sessionIdOverride != purchase.lastSessionId + ) { + logger.trace(`changing session ID to ${sessionIdOverride}`); + await oneShotMutate(ws.db, 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 contractAmount = Amounts.parseOrThrow(d.contractTerms.amount); + + let wireFeeLimit; + if (!d.contractTerms.max_wire_fee) { + wireFeeLimit = Amounts.getZero(contractAmount.currency); + } else { + wireFeeLimit = Amounts.parseOrThrow(d.contractTerms.max_wire_fee); + } + + const res = await getCoinsForPayment(ws, { + allowedAuditors: d.contractTerms.auditors, + allowedExchanges: d.contractTerms.exchanges, + depositFeeLimit: Amounts.parseOrThrow(d.contractTerms.max_fee), + paymentAmount: Amounts.parseOrThrow(d.contractTerms.amount), + wireFeeAmortization: d.contractTerms.wire_fee_amortization || 1, + wireFeeLimit, + wireFeeTime: extractTalerStampOrThrow(d.contractTerms.timestamp), + wireMethod: d.contractTerms.wire_method, + }); + + logger.trace("coin selection result", res); + + if (!res) { + // Should not happen, since checkPay should be called first + console.log("not confirming payment, insufficient coins"); + throw Error("insufficient balance"); + } + + const sd = await getSpeculativePayData(ws, proposalId); + if (!sd) { + const { exchangeUrl, cds, totalAmount } = res; + const payCoinInfo = await ws.cryptoApi.signDeposit( + d.contractTerms, + cds, + totalAmount, + ); + purchase = await recordConfirmPay( + ws, + proposal, + payCoinInfo, + exchangeUrl, + sessionIdOverride, + ); + } else { + purchase = await recordConfirmPay( + ws, + sd.proposal, + sd.payCoinInfo, + sd.exchangeUrl, + sessionIdOverride, + ); + } + + logger.trace("confirmPay: submitting payment after creating purchase record"); + return submitPay(ws, proposalId); +} + +export async function getFullRefundFees( + ws: InternalWalletState, + refundPermissions: MerchantRefundPermission[], +): Promise<AmountJson> { + if (refundPermissions.length === 0) { + throw Error("no refunds given"); + } + const coin0 = await oneShotGet( + ws.db, + Stores.coins, + refundPermissions[0].coin_pub, + ); + if (!coin0) { + throw Error("coin not found"); + } + let feeAcc = Amounts.getZero( + Amounts.parseOrThrow(refundPermissions[0].refund_amount).currency, + ); + + const denoms = await oneShotIterIndex( + ws.db, + Stores.denominations.exchangeBaseUrlIndex, + coin0.exchangeBaseUrl, + ).toArray(); + + for (const rp of refundPermissions) { + const coin = await oneShotGet(ws.db, Stores.coins, rp.coin_pub); + if (!coin) { + throw Error("coin not found"); + } + const denom = await oneShotGet(ws.db, Stores.denominations, [ + coin0.exchangeBaseUrl, + coin.denomPub, + ]); + if (!denom) { + throw Error(`denom not found (${coin.denomPub})`); + } + // FIXME: this assumes that the refund already happened. + // When it hasn't, the refresh cost is inaccurate. To fix this, + // we need introduce a flag to tell if a coin was refunded or + // refreshed normally (and what about incremental refunds?) + const refundAmount = Amounts.parseOrThrow(rp.refund_amount); + const refundFee = Amounts.parseOrThrow(rp.refund_fee); + const refreshCost = getTotalRefreshCost( + denoms, + denom, + Amounts.sub(refundAmount, refundFee).amount, + ); + feeAcc = Amounts.add(feeAcc, refreshCost, refundFee).amount; + } + return feeAcc; +} + +async function acceptRefundResponse( + ws: InternalWalletState, + proposalId: string, + refundResponse: MerchantRefundResponse, +): Promise<void> { + const refundPermissions = refundResponse.refund_permissions; + + let numNewRefunds = 0; + + await runWithWriteTransaction(ws.db, [Stores.purchases], async tx => { + const p = await tx.get(Stores.purchases, proposalId); + if (!p) { + console.error("purchase not found, not adding refunds"); + return; + } + + if (!p.refundStatusRequested) { + return; + } + + for (const perm of refundPermissions) { + if ( + !p.refundsPending[perm.merchant_sig] && + !p.refundsDone[perm.merchant_sig] + ) { + p.refundsPending[perm.merchant_sig] = perm; + numNewRefunds++; + } + } + + // Are we done with querying yet, or do we need to do another round + // after a retry delay? + let queryDone = true; + + if (numNewRefunds === 0) { + if ( + p.autoRefundDeadline && + p.autoRefundDeadline.t_ms > getTimestampNow().t_ms + ) { + queryDone = false; + } + } + + if (queryDone) { + p.lastRefundStatusTimestamp = getTimestampNow(); + p.lastRefundStatusError = undefined; + p.refundStatusRetryInfo = initRetryInfo(); + p.refundStatusRequested = false; + console.log("refund query done"); + } else { + // No error, but we need to try again! + p.lastRefundStatusTimestamp = getTimestampNow(); + p.refundStatusRetryInfo.retryCounter++; + updateRetryInfoTimeout(p.refundStatusRetryInfo); + p.lastRefundStatusError = undefined; + console.log("refund query not done"); + } + + if (numNewRefunds) { + p.lastRefundApplyError = undefined; + p.refundApplyRetryInfo = initRetryInfo(); + } + + await tx.put(Stores.purchases, p); + }); + ws.notify({ + type: NotificationType.RefundQueried, + }); + if (numNewRefunds > 0) { + await processPurchaseApplyRefund(ws, proposalId); + } +} + +async function startRefundQuery( + ws: InternalWalletState, + proposalId: string, +): Promise<void> { + const success = await runWithWriteTransaction( + ws.db, + [Stores.purchases], + async tx => { + const p = await tx.get(Stores.purchases, proposalId); + if (!p) { + console.log("no purchase found for refund URL"); + return false; + } + p.refundStatusRequested = true; + p.lastRefundStatusError = undefined; + p.refundStatusRetryInfo = initRetryInfo(); + await tx.put(Stores.purchases, p); + return true; + }, + ); + + if (!success) { + return; + } + + ws.notify({ + type: NotificationType.RefundStarted, + }); + + await processPurchaseQueryRefund(ws, proposalId); +} + +/** + * 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<string> { + const parseResult = parseRefundUri(talerRefundUri); + + console.log("applying refund"); + + if (!parseResult) { + throw Error("invalid refund URI"); + } + + const purchase = await oneShotGetIndexed( + ws.db, + Stores.purchases.orderIdIndex, + [parseResult.merchantBaseUrl, parseResult.orderId], + ); + + if (!purchase) { + throw Error("no purchase for the taler://refund/ URI was found"); + } + + console.log("processing purchase for refund"); + await startRefundQuery(ws, purchase.proposalId); + + return purchase.contractTermsHash; +} + +export async function processPurchasePay( + ws: InternalWalletState, + proposalId: string, + forceNow: boolean = false, +): Promise<void> { + const onOpErr = (e: OperationError) => + incrementPurchasePayRetry(ws, proposalId, e); + await guardOperationException( + () => processPurchasePayImpl(ws, proposalId, forceNow), + onOpErr, + ); +} + +async function resetPurchasePayRetry( + ws: InternalWalletState, + proposalId: string, +) { + await oneShotMutate(ws.db, Stores.purchases, proposalId, x => { + if (x.payRetryInfo.active) { + x.payRetryInfo = initRetryInfo(); + } + return x; + }); +} + +async function processPurchasePayImpl( + ws: InternalWalletState, + proposalId: string, + forceNow: boolean, +): Promise<void> { + if (forceNow) { + await resetPurchasePayRetry(ws, proposalId); + } + const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId); + if (!purchase) { + return; + } + if (!purchase.paymentSubmitPending) { + return; + } + logger.trace(`processing purchase pay ${proposalId}`); + await submitPay(ws, proposalId); +} + +export async function processPurchaseQueryRefund( + ws: InternalWalletState, + proposalId: string, + forceNow: boolean = false, +): Promise<void> { + const onOpErr = (e: OperationError) => + incrementPurchaseQueryRefundRetry(ws, proposalId, e); + await guardOperationException( + () => processPurchaseQueryRefundImpl(ws, proposalId, forceNow), + onOpErr, + ); +} + +async function resetPurchaseQueryRefundRetry( + ws: InternalWalletState, + proposalId: string, +) { + await oneShotMutate(ws.db, Stores.purchases, proposalId, x => { + if (x.refundStatusRetryInfo.active) { + x.refundStatusRetryInfo = initRetryInfo(); + } + return x; + }); +} + +async function processPurchaseQueryRefundImpl( + ws: InternalWalletState, + proposalId: string, + forceNow: boolean, +): Promise<void> { + if (forceNow) { + await resetPurchaseQueryRefundRetry(ws, proposalId); + } + const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId); + if (!purchase) { + return; + } + if (!purchase.refundStatusRequested) { + return; + } + + const refundUrlObj = new URL( + "refund", + purchase.contractTerms.merchant_base_url, + ); + refundUrlObj.searchParams.set("order_id", purchase.contractTerms.order_id); + const refundUrl = refundUrlObj.href; + let resp; + try { + resp = await ws.http.get(refundUrl); + } catch (e) { + console.error("error downloading refund permission", e); + throw e; + } + if (resp.status !== 200) { + throw Error(`unexpected status code (${resp.status}) for /refund`); + } + + const refundResponse = MerchantRefundResponse.checked(await resp.json()); + await acceptRefundResponse(ws, proposalId, refundResponse); +} + +export async function processPurchaseApplyRefund( + ws: InternalWalletState, + proposalId: string, + forceNow: boolean = false, +): Promise<void> { + const onOpErr = (e: OperationError) => + incrementPurchaseApplyRefundRetry(ws, proposalId, e); + await guardOperationException( + () => processPurchaseApplyRefundImpl(ws, proposalId, forceNow), + onOpErr, + ); +} + +async function resetPurchaseApplyRefundRetry( + ws: InternalWalletState, + proposalId: string, +) { + await oneShotMutate(ws.db, Stores.purchases, proposalId, x => { + if (x.refundApplyRetryInfo.active) { + x.refundApplyRetryInfo = initRetryInfo(); + } + return x; + }); +} + +async function processPurchaseApplyRefundImpl( + ws: InternalWalletState, + proposalId: string, + forceNow: boolean, +): Promise<void> { + if (forceNow) { + await resetPurchaseApplyRefundRetry(ws, proposalId); + } + const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId); + if (!purchase) { + console.error("not submitting refunds, payment not found:"); + return; + } + const pendingKeys = Object.keys(purchase.refundsPending); + if (pendingKeys.length === 0) { + console.log("no pending refunds"); + return; + } + for (const pk of pendingKeys) { + const perm = purchase.refundsPending[pk]; + const req: RefundRequest = { + coin_pub: perm.coin_pub, + h_contract_terms: purchase.contractTermsHash, + merchant_pub: purchase.contractTerms.merchant_pub, + merchant_sig: perm.merchant_sig, + refund_amount: perm.refund_amount, + refund_fee: perm.refund_fee, + rtransaction_id: perm.rtransaction_id, + }; + console.log("sending refund permission", perm); + // FIXME: not correct once we support multiple exchanges per payment + const exchangeUrl = purchase.payReq.coins[0].exchange_url; + const reqUrl = new URL("refund", exchangeUrl); + const resp = await ws.http.postJson(reqUrl.href, req); + console.log("sent refund permission"); + if (resp.status !== 200) { + console.error("refund failed", resp); + continue; + } + + let allRefundsProcessed = false; + + await runWithWriteTransaction( + ws.db, + [Stores.purchases, Stores.coins], + async tx => { + const p = await tx.get(Stores.purchases, proposalId); + if (!p) { + return; + } + if (p.refundsPending[pk]) { + p.refundsDone[pk] = p.refundsPending[pk]; + delete p.refundsPending[pk]; + } + if (Object.keys(p.refundsPending).length === 0) { + p.refundStatusRetryInfo = initRetryInfo(); + p.lastRefundStatusError = undefined; + allRefundsProcessed = true; + } + await tx.put(Stores.purchases, p); + const c = await tx.get(Stores.coins, perm.coin_pub); + if (!c) { + console.warn("coin not found, can't apply refund"); + return; + } + const refundAmount = Amounts.parseOrThrow(perm.refund_amount); + const refundFee = Amounts.parseOrThrow(perm.refund_fee); + c.status = CoinStatus.Dirty; + c.currentAmount = Amounts.add(c.currentAmount, refundAmount).amount; + c.currentAmount = Amounts.sub(c.currentAmount, refundFee).amount; + await tx.put(Stores.coins, c); + }, + ); + if (allRefundsProcessed) { + ws.notify({ + type: NotificationType.RefundFinished, + }); + } + await refresh(ws, perm.coin_pub); + } + + ws.notify({ + type: NotificationType.RefundsSubmitted, + proposalId, + }); +} |