aboutsummaryrefslogtreecommitdiff
path: root/src/wallet-impl/pay.ts
diff options
context:
space:
mode:
authorFlorian Dold <florian.dold@gmail.com>2019-12-12 20:53:15 +0100
committerFlorian Dold <florian.dold@gmail.com>2019-12-12 20:53:15 +0100
commit74433c3e05734aa1194049fcbcaa92c70ce61c74 (patch)
treed30e79c9ac3fd5720de628f6a9764354ec69c648 /src/wallet-impl/pay.ts
parentcc137c87394ec34d2f54d69fe896dfdf3feec5ea (diff)
downloadwallet-core-74433c3e05734aa1194049fcbcaa92c70ce61c74.tar.xz
refactor: re-structure type definitions
Diffstat (limited to 'src/wallet-impl/pay.ts')
-rw-r--r--src/wallet-impl/pay.ts1494
1 files changed, 0 insertions, 1494 deletions
diff --git a/src/wallet-impl/pay.ts b/src/wallet-impl/pay.ts
deleted file mode 100644
index af9d44066..000000000
--- a/src/wallet-impl/pay.ts
+++ /dev/null
@@ -1,1494 +0,0 @@
-/*
- 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 "../talerTypes";
-import {
- Timestamp,
- CoinSelectionResult,
- CoinWithDenom,
- PayCoinInfo,
- getTimestampNow,
- PreparePayResult,
- ConfirmPayResult,
- OperationError,
- NotificationType,
-} from "../walletTypes";
-import {
- oneShotIter,
- oneShotIterIndex,
- oneShotGet,
- runWithWriteTransaction,
- oneShotPut,
- oneShotGetIndexed,
- oneShotMutate,
-} from "../util/query";
-import {
- Stores,
- CoinStatus,
- DenominationRecord,
- ProposalRecord,
- PurchaseRecord,
- CoinRecord,
- ProposalStatus,
- initRetryInfo,
- updateRetryInfoTimeout,
-} from "../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";
-
-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,
- });
-}