2019-12-02 00:42:40 +01:00
|
|
|
/*
|
|
|
|
This file is part of GNU Taler
|
2019-12-15 19:04:14 +01:00
|
|
|
(C) Taler Systems S.A.
|
2019-12-02 00:42:40 +01:00
|
|
|
|
|
|
|
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/>
|
|
|
|
*/
|
|
|
|
|
2019-12-15 19:04:14 +01:00
|
|
|
/**
|
|
|
|
* Implementation of the payment operation, including downloading and
|
|
|
|
* claiming of proposals.
|
|
|
|
*
|
|
|
|
* @author Florian Dold
|
|
|
|
*/
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Imports.
|
|
|
|
*/
|
2019-12-15 19:08:07 +01:00
|
|
|
import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto";
|
|
|
|
import {
|
|
|
|
CoinRecord,
|
|
|
|
CoinStatus,
|
|
|
|
DenominationRecord,
|
|
|
|
initRetryInfo,
|
|
|
|
ProposalRecord,
|
|
|
|
ProposalStatus,
|
|
|
|
PurchaseRecord,
|
|
|
|
RefundReason,
|
|
|
|
Stores,
|
|
|
|
updateRetryInfoTimeout,
|
|
|
|
} from "../types/dbTypes";
|
|
|
|
import { NotificationType } from "../types/notifications";
|
2019-12-02 00:42:40 +01:00
|
|
|
import {
|
|
|
|
Auditor,
|
2019-12-15 19:08:07 +01:00
|
|
|
ContractTerms,
|
2019-12-02 00:42:40 +01:00
|
|
|
ExchangeHandle,
|
|
|
|
MerchantRefundResponse,
|
|
|
|
PayReq,
|
|
|
|
Proposal,
|
2019-12-12 20:53:15 +01:00
|
|
|
} from "../types/talerTypes";
|
2019-12-02 00:42:40 +01:00
|
|
|
import {
|
|
|
|
CoinSelectionResult,
|
|
|
|
CoinWithDenom,
|
|
|
|
ConfirmPayResult,
|
2019-12-15 19:08:07 +01:00
|
|
|
getTimestampNow,
|
2019-12-05 19:38:19 +01:00
|
|
|
OperationError,
|
2019-12-15 19:08:07 +01:00
|
|
|
PayCoinInfo,
|
|
|
|
PreparePayResult,
|
2019-12-15 16:59:00 +01:00
|
|
|
RefreshReason,
|
2019-12-15 19:08:07 +01:00
|
|
|
Timestamp,
|
2019-12-12 20:53:15 +01:00
|
|
|
} from "../types/walletTypes";
|
2019-12-02 00:42:40 +01:00
|
|
|
import * as Amounts from "../util/amounts";
|
2019-12-15 19:08:07 +01:00
|
|
|
import { AmountJson } from "../util/amounts";
|
2019-12-02 00:42:40 +01:00
|
|
|
import {
|
|
|
|
amountToPretty,
|
|
|
|
canonicalJson,
|
2019-12-07 18:42:18 +01:00
|
|
|
extractTalerDuration,
|
2019-12-15 19:08:07 +01:00
|
|
|
extractTalerStampOrThrow,
|
|
|
|
strcmp,
|
2019-12-02 00:42:40 +01:00
|
|
|
} from "../util/helpers";
|
|
|
|
import { Logger } from "../util/logging";
|
2019-12-15 19:08:07 +01:00
|
|
|
import { getOrderDownloadUrl, parsePayUri } from "../util/taleruri";
|
2019-12-05 19:38:19 +01:00
|
|
|
import { guardOperationException } from "./errors";
|
2019-12-15 19:08:07 +01:00
|
|
|
import { createRefreshGroup, getTotalRefreshCost } from "./refresh";
|
2019-12-15 19:04:14 +01:00
|
|
|
import { acceptRefundResponse } from "./refund";
|
2019-12-15 19:08:07 +01:00
|
|
|
import { InternalWalletState } from "./state";
|
2019-12-02 00:42:40 +01:00
|
|
|
|
|
|
|
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;
|
|
|
|
|
2019-12-12 22:39:45 +01:00
|
|
|
const exchanges = await ws.db.iter(Stores.exchanges).toArray();
|
2019-12-02 00:42:40 +01:00
|
|
|
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2019-12-15 19:04:14 +01:00
|
|
|
const coins = await ws.db
|
|
|
|
.iterIndex(Stores.coins.exchangeBaseUrlIndex, exchange.baseUrl)
|
|
|
|
.toArray();
|
2019-12-02 00:42:40 +01:00
|
|
|
|
2019-12-15 19:04:14 +01:00
|
|
|
const denoms = await ws.db
|
|
|
|
.iterIndex(Stores.denominations.exchangeBaseUrlIndex, exchange.baseUrl)
|
|
|
|
.toArray();
|
2019-12-02 00:42:40 +01:00
|
|
|
|
|
|
|
if (!coins || coins.length === 0) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Denomination of the first coin, we assume that all other
|
|
|
|
// coins have the same currency
|
2019-12-12 22:39:45 +01:00
|
|
|
const firstDenom = await ws.db.get(Stores.denominations, [
|
2019-12-02 00:42:40 +01:00
|
|
|
exchange.baseUrl,
|
|
|
|
coins[0].denomPub,
|
|
|
|
]);
|
|
|
|
if (!firstDenom) {
|
|
|
|
throw Error("db inconsistent");
|
|
|
|
}
|
|
|
|
const currency = firstDenom.value.currency;
|
|
|
|
const cds: CoinWithDenom[] = [];
|
|
|
|
for (const coin of coins) {
|
2019-12-12 22:39:45 +01:00
|
|
|
const denom = await ws.db.get(Stores.denominations, [
|
2019-12-02 00:42:40 +01:00
|
|
|
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,
|
2019-12-10 12:22:29 +01:00
|
|
|
sessionIdOverride: string | undefined,
|
2019-12-02 00:42:40 +01:00
|
|
|
): Promise<PurchaseRecord> {
|
2019-12-03 00:52:15 +01:00
|
|
|
const d = proposal.download;
|
|
|
|
if (!d) {
|
|
|
|
throw Error("proposal is in invalid state");
|
|
|
|
}
|
2019-12-10 12:22:29 +01:00
|
|
|
let sessionId;
|
|
|
|
if (sessionIdOverride) {
|
|
|
|
sessionId = sessionIdOverride;
|
|
|
|
} else {
|
|
|
|
sessionId = proposal.downloadSessionId;
|
|
|
|
}
|
|
|
|
logger.trace(`recording payment with session ID ${sessionId}`);
|
2019-12-02 00:42:40 +01:00
|
|
|
const payReq: PayReq = {
|
|
|
|
coins: payCoinInfo.sigs,
|
2019-12-03 00:52:15 +01:00
|
|
|
merchant_pub: d.contractTerms.merchant_pub,
|
2019-12-02 00:42:40 +01:00
|
|
|
mode: "pay",
|
2019-12-03 00:52:15 +01:00
|
|
|
order_id: d.contractTerms.order_id,
|
2019-12-02 00:42:40 +01:00
|
|
|
};
|
|
|
|
const t: PurchaseRecord = {
|
|
|
|
abortDone: false,
|
|
|
|
abortRequested: false,
|
2019-12-03 00:52:15 +01:00
|
|
|
contractTerms: d.contractTerms,
|
|
|
|
contractTermsHash: d.contractTermsHash,
|
2019-12-10 12:22:29 +01:00
|
|
|
lastSessionId: sessionId,
|
2019-12-03 00:52:15 +01:00
|
|
|
merchantSig: d.merchantSig,
|
2019-12-02 00:42:40 +01:00
|
|
|
payReq,
|
2019-12-05 19:38:19 +01:00
|
|
|
acceptTimestamp: getTimestampNow(),
|
2019-12-06 00:24:34 +01:00
|
|
|
lastRefundStatusTimestamp: undefined,
|
2019-12-03 00:52:15 +01:00
|
|
|
proposalId: proposal.proposalId,
|
2019-12-06 00:24:34 +01:00
|
|
|
lastPayError: undefined,
|
|
|
|
lastRefundStatusError: undefined,
|
|
|
|
payRetryInfo: initRetryInfo(),
|
|
|
|
refundStatusRetryInfo: initRetryInfo(),
|
|
|
|
refundStatusRequested: false,
|
|
|
|
lastRefundApplyError: undefined,
|
|
|
|
refundApplyRetryInfo: initRetryInfo(),
|
2019-12-06 02:52:16 +01:00
|
|
|
firstSuccessfulPayTimestamp: undefined,
|
2019-12-07 18:42:18 +01:00
|
|
|
autoRefundDeadline: undefined,
|
2019-12-10 12:22:29 +01:00
|
|
|
paymentSubmitPending: true,
|
2019-12-15 19:04:14 +01:00
|
|
|
refundState: {
|
|
|
|
refundGroups: [],
|
|
|
|
refundsDone: {},
|
|
|
|
refundsFailed: {},
|
|
|
|
refundsPending: {},
|
|
|
|
},
|
2019-12-02 00:42:40 +01:00
|
|
|
};
|
|
|
|
|
2019-12-12 22:39:45 +01:00
|
|
|
await ws.db.runWithWriteTransaction(
|
2019-12-03 00:52:15 +01:00
|
|
|
[Stores.coins, Stores.purchases, Stores.proposals],
|
2019-12-02 00:42:40 +01:00
|
|
|
async tx => {
|
2019-12-03 00:52:15 +01:00
|
|
|
const p = await tx.get(Stores.proposals, proposal.proposalId);
|
|
|
|
if (p) {
|
|
|
|
p.proposalStatus = ProposalStatus.ACCEPTED;
|
2019-12-05 22:17:01 +01:00
|
|
|
p.lastError = undefined;
|
|
|
|
p.retryInfo = initRetryInfo(false);
|
2019-12-03 00:52:15 +01:00
|
|
|
await tx.put(Stores.proposals, p);
|
|
|
|
}
|
2019-12-02 00:42:40 +01:00
|
|
|
await tx.put(Stores.purchases, t);
|
|
|
|
for (let c of payCoinInfo.updatedCoins) {
|
|
|
|
await tx.put(Stores.coins, c);
|
|
|
|
}
|
|
|
|
},
|
|
|
|
);
|
|
|
|
|
2019-12-05 19:38:19 +01:00
|
|
|
ws.notify({
|
|
|
|
type: NotificationType.ProposalAccepted,
|
|
|
|
proposalId: proposal.proposalId,
|
|
|
|
});
|
2019-12-02 00:42:40 +01:00
|
|
|
return t;
|
|
|
|
}
|
|
|
|
|
|
|
|
function getNextUrl(contractTerms: ContractTerms): string {
|
2019-12-02 17:35:47 +01:00
|
|
|
const f = contractTerms.fulfillment_url;
|
|
|
|
if (f.startsWith("http://") || f.startsWith("https://")) {
|
2019-12-03 00:52:15 +01:00
|
|
|
const fu = new URL(contractTerms.fulfillment_url);
|
2019-12-02 17:35:47 +01:00
|
|
|
fu.searchParams.set("order_id", contractTerms.order_id);
|
|
|
|
return fu.href;
|
|
|
|
} else {
|
|
|
|
return f;
|
|
|
|
}
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
export async function abortFailedPayment(
|
|
|
|
ws: InternalWalletState,
|
2019-12-03 00:52:15 +01:00
|
|
|
proposalId: string,
|
2019-12-02 00:42:40 +01:00
|
|
|
): Promise<void> {
|
2019-12-12 22:39:45 +01:00
|
|
|
const purchase = await ws.db.get(Stores.purchases, proposalId);
|
2019-12-02 00:42:40 +01:00
|
|
|
if (!purchase) {
|
|
|
|
throw Error("Purchase not found, unable to abort with refund");
|
|
|
|
}
|
2019-12-06 02:52:16 +01:00
|
|
|
if (purchase.firstSuccessfulPayTimestamp) {
|
2019-12-02 00:42:40 +01:00
|
|
|
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.
|
2019-12-12 22:39:45 +01:00
|
|
|
await ws.db.put(Stores.purchases, purchase);
|
2019-12-02 00:42:40 +01:00
|
|
|
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2019-12-09 13:29:11 +01:00
|
|
|
if (resp.status !== 200) {
|
|
|
|
throw Error(`unexpected status for /pay (${resp.status})`);
|
|
|
|
}
|
|
|
|
|
|
|
|
const refundResponse = MerchantRefundResponse.checked(await resp.json());
|
2019-12-15 19:04:14 +01:00
|
|
|
await acceptRefundResponse(
|
|
|
|
ws,
|
|
|
|
purchase.proposalId,
|
|
|
|
refundResponse,
|
|
|
|
RefundReason.AbortRefund,
|
|
|
|
);
|
2019-12-02 00:42:40 +01:00
|
|
|
|
2019-12-12 22:39:45 +01:00
|
|
|
await ws.db.runWithWriteTransaction([Stores.purchases], async tx => {
|
2019-12-03 00:52:15 +01:00
|
|
|
const p = await tx.get(Stores.purchases, proposalId);
|
2019-12-02 00:42:40 +01:00
|
|
|
if (!p) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
p.abortDone = true;
|
|
|
|
await tx.put(Stores.purchases, p);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2019-12-05 19:38:19 +01:00
|
|
|
async function incrementProposalRetry(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
proposalId: string,
|
|
|
|
err: OperationError | undefined,
|
|
|
|
): Promise<void> {
|
2019-12-12 22:39:45 +01:00
|
|
|
await ws.db.runWithWriteTransaction([Stores.proposals], async tx => {
|
2019-12-05 19:38:19 +01:00
|
|
|
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);
|
|
|
|
});
|
2019-12-06 02:52:16 +01:00
|
|
|
ws.notify({ type: NotificationType.ProposalOperationError });
|
2019-12-05 19:38:19 +01:00
|
|
|
}
|
|
|
|
|
2019-12-06 00:24:34 +01:00
|
|
|
async function incrementPurchasePayRetry(
|
2019-12-05 19:38:19 +01:00
|
|
|
ws: InternalWalletState,
|
|
|
|
proposalId: string,
|
|
|
|
err: OperationError | undefined,
|
|
|
|
): Promise<void> {
|
2019-12-06 00:24:34 +01:00
|
|
|
console.log("incrementing purchase pay retry with error", err);
|
2019-12-12 22:39:45 +01:00
|
|
|
await ws.db.runWithWriteTransaction([Stores.purchases], async tx => {
|
2019-12-05 19:38:19 +01:00
|
|
|
const pr = await tx.get(Stores.purchases, proposalId);
|
|
|
|
if (!pr) {
|
|
|
|
return;
|
|
|
|
}
|
2019-12-06 00:24:34 +01:00
|
|
|
if (!pr.payRetryInfo) {
|
2019-12-05 19:38:19 +01:00
|
|
|
return;
|
|
|
|
}
|
2019-12-06 00:24:34 +01:00
|
|
|
pr.payRetryInfo.retryCounter++;
|
|
|
|
updateRetryInfoTimeout(pr.payRetryInfo);
|
|
|
|
pr.lastPayError = err;
|
|
|
|
await tx.put(Stores.purchases, pr);
|
|
|
|
});
|
2019-12-06 02:52:16 +01:00
|
|
|
ws.notify({ type: NotificationType.PayOperationError });
|
2019-12-06 00:24:34 +01:00
|
|
|
}
|
|
|
|
|
2019-12-03 00:52:15 +01:00
|
|
|
export async function processDownloadProposal(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
proposalId: string,
|
2019-12-07 22:02:11 +01:00
|
|
|
forceNow: boolean = false,
|
2019-12-05 19:38:19 +01:00
|
|
|
): Promise<void> {
|
|
|
|
const onOpErr = (err: OperationError) =>
|
|
|
|
incrementProposalRetry(ws, proposalId, err);
|
|
|
|
await guardOperationException(
|
2019-12-07 22:02:11 +01:00
|
|
|
() => processDownloadProposalImpl(ws, proposalId, forceNow),
|
2019-12-05 19:38:19 +01:00
|
|
|
onOpErr,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2019-12-07 22:02:11 +01:00
|
|
|
async function resetDownloadProposalRetry(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
proposalId: string,
|
|
|
|
) {
|
2019-12-12 22:39:45 +01:00
|
|
|
await ws.db.mutate(Stores.proposals, proposalId, x => {
|
2019-12-07 22:02:11 +01:00
|
|
|
if (x.retryInfo.active) {
|
|
|
|
x.retryInfo = initRetryInfo();
|
|
|
|
}
|
|
|
|
return x;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2019-12-05 19:38:19 +01:00
|
|
|
async function processDownloadProposalImpl(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
proposalId: string,
|
2019-12-07 22:02:11 +01:00
|
|
|
forceNow: boolean,
|
2019-12-03 00:52:15 +01:00
|
|
|
): Promise<void> {
|
2019-12-07 22:02:11 +01:00
|
|
|
if (forceNow) {
|
|
|
|
await resetDownloadProposalRetry(ws, proposalId);
|
|
|
|
}
|
2019-12-12 22:39:45 +01:00
|
|
|
const proposal = await ws.db.get(Stores.proposals, proposalId);
|
2019-12-03 00:52:15 +01:00
|
|
|
if (!proposal) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (proposal.proposalStatus != ProposalStatus.DOWNLOADING) {
|
|
|
|
return;
|
|
|
|
}
|
2019-12-07 22:02:11 +01:00
|
|
|
|
|
|
|
const parsedUrl = new URL(
|
|
|
|
getOrderDownloadUrl(proposal.merchantBaseUrl, proposal.orderId),
|
|
|
|
);
|
2019-12-06 12:47:28 +01:00
|
|
|
parsedUrl.searchParams.set("nonce", proposal.noncePub);
|
|
|
|
const urlWithNonce = parsedUrl.href;
|
2019-12-03 00:52:15 +01:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2019-12-09 13:29:11 +01:00
|
|
|
if (resp.status !== 200) {
|
|
|
|
throw Error(`contract download failed with status ${resp.status}`);
|
|
|
|
}
|
|
|
|
|
|
|
|
const proposalResp = Proposal.checked(await resp.json());
|
2019-12-03 00:52:15 +01:00
|
|
|
|
|
|
|
const contractTermsHash = await ws.cryptoApi.hashString(
|
|
|
|
canonicalJson(proposalResp.contract_terms),
|
|
|
|
);
|
|
|
|
|
|
|
|
const fulfillmentUrl = proposalResp.contract_terms.fulfillment_url;
|
|
|
|
|
2019-12-12 22:39:45 +01:00
|
|
|
await ws.db.runWithWriteTransaction(
|
2019-12-03 00:52:15 +01:00
|
|
|
[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) {
|
2019-12-06 00:56:31 +01:00
|
|
|
console.log("repurchase detected");
|
2019-12-03 00:52:15 +01:00
|
|
|
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);
|
|
|
|
},
|
|
|
|
);
|
|
|
|
|
2019-12-05 19:38:19 +01:00
|
|
|
ws.notify({
|
|
|
|
type: NotificationType.ProposalDownloaded,
|
|
|
|
proposalId: proposal.proposalId,
|
|
|
|
});
|
2019-12-03 00:52:15 +01:00
|
|
|
}
|
|
|
|
|
2019-12-02 00:42:40 +01:00
|
|
|
/**
|
|
|
|
* 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.
|
|
|
|
*/
|
2019-12-03 00:52:15 +01:00
|
|
|
async function startDownloadProposal(
|
2019-12-02 00:42:40 +01:00
|
|
|
ws: InternalWalletState,
|
2019-12-06 12:47:28 +01:00
|
|
|
merchantBaseUrl: string,
|
|
|
|
orderId: string,
|
2019-12-10 12:22:29 +01:00
|
|
|
sessionId: string | undefined,
|
2019-12-02 00:42:40 +01:00
|
|
|
): Promise<string> {
|
2019-12-12 22:39:45 +01:00
|
|
|
const oldProposal = await ws.db.getIndexed(
|
2019-12-06 12:47:28 +01:00
|
|
|
Stores.proposals.urlAndOrderIdIndex,
|
|
|
|
[merchantBaseUrl, orderId],
|
2019-12-02 00:42:40 +01:00
|
|
|
);
|
|
|
|
if (oldProposal) {
|
2019-12-03 00:52:15 +01:00
|
|
|
await processDownloadProposal(ws, oldProposal.proposalId);
|
2019-12-02 00:42:40 +01:00
|
|
|
return oldProposal.proposalId;
|
|
|
|
}
|
|
|
|
|
|
|
|
const { priv, pub } = await ws.cryptoApi.createEddsaKeypair();
|
|
|
|
const proposalId = encodeCrock(getRandomBytes(32));
|
|
|
|
|
|
|
|
const proposalRecord: ProposalRecord = {
|
2019-12-03 00:52:15 +01:00
|
|
|
download: undefined,
|
2019-12-02 00:42:40 +01:00
|
|
|
noncePriv: priv,
|
2019-12-03 00:52:15 +01:00
|
|
|
noncePub: pub,
|
2019-12-02 00:42:40 +01:00
|
|
|
timestamp: getTimestampNow(),
|
2019-12-06 12:47:28 +01:00
|
|
|
merchantBaseUrl,
|
|
|
|
orderId,
|
2019-12-02 00:42:40 +01:00
|
|
|
proposalId: proposalId,
|
2019-12-03 00:52:15 +01:00
|
|
|
proposalStatus: ProposalStatus.DOWNLOADING,
|
|
|
|
repurchaseProposalId: undefined,
|
2019-12-05 19:38:19 +01:00
|
|
|
retryInfo: initRetryInfo(),
|
|
|
|
lastError: undefined,
|
2019-12-10 12:22:29 +01:00
|
|
|
downloadSessionId: sessionId,
|
2019-12-02 00:42:40 +01:00
|
|
|
};
|
|
|
|
|
2019-12-15 19:04:14 +01:00
|
|
|
await ws.db.runWithWriteTransaction([Stores.proposals], async tx => {
|
|
|
|
const existingRecord = await tx.getIndexed(
|
|
|
|
Stores.proposals.urlAndOrderIdIndex,
|
|
|
|
[merchantBaseUrl, orderId],
|
|
|
|
);
|
2019-12-10 12:22:29 +01:00
|
|
|
if (existingRecord) {
|
|
|
|
// Created concurrently
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
await tx.put(Stores.proposals, proposalRecord);
|
|
|
|
});
|
|
|
|
|
2019-12-03 00:52:15 +01:00
|
|
|
await processDownloadProposal(ws, proposalId);
|
2019-12-02 00:42:40 +01:00
|
|
|
return proposalId;
|
|
|
|
}
|
|
|
|
|
2019-12-03 00:52:15 +01:00
|
|
|
export async function submitPay(
|
2019-12-02 00:42:40 +01:00
|
|
|
ws: InternalWalletState,
|
2019-12-03 00:52:15 +01:00
|
|
|
proposalId: string,
|
2019-12-02 00:42:40 +01:00
|
|
|
): Promise<ConfirmPayResult> {
|
2019-12-12 22:39:45 +01:00
|
|
|
const purchase = await ws.db.get(Stores.purchases, proposalId);
|
2019-12-02 00:42:40 +01:00
|
|
|
if (!purchase) {
|
2019-12-03 00:52:15 +01:00
|
|
|
throw Error("Purchase not found: " + proposalId);
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
|
|
|
if (purchase.abortRequested) {
|
|
|
|
throw Error("not submitting payment for aborted purchase");
|
|
|
|
}
|
2019-12-10 12:22:29 +01:00
|
|
|
const sessionId = purchase.lastSessionId;
|
2019-12-02 00:42:40 +01:00
|
|
|
let resp;
|
|
|
|
const payReq = { ...purchase.payReq, session_id: sessionId };
|
|
|
|
|
2019-12-10 12:22:29 +01:00
|
|
|
console.log("paying with session ID", sessionId);
|
|
|
|
|
2019-12-02 00:42:40 +01:00
|
|
|
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;
|
|
|
|
}
|
2019-12-09 13:29:11 +01:00
|
|
|
if (resp.status !== 200) {
|
|
|
|
throw Error(`unexpected status (${resp.status}) for /pay`);
|
|
|
|
}
|
|
|
|
const merchantResp = await resp.json();
|
2019-12-10 12:22:29 +01:00
|
|
|
console.log("got success from pay URL", merchantResp);
|
2019-12-02 00:42:40 +01:00
|
|
|
|
|
|
|
const merchantPub = purchase.contractTerms.merchant_pub;
|
|
|
|
const valid: boolean = await ws.cryptoApi.isValidPaymentSignature(
|
|
|
|
merchantResp.sig,
|
2019-12-03 00:52:15 +01:00
|
|
|
purchase.contractTermsHash,
|
2019-12-02 00:42:40 +01:00
|
|
|
merchantPub,
|
|
|
|
);
|
|
|
|
if (!valid) {
|
|
|
|
console.error("merchant payment signature invalid");
|
|
|
|
// FIXME: properly display error
|
|
|
|
throw Error("merchant payment signature invalid");
|
|
|
|
}
|
2019-12-07 18:42:18 +01:00
|
|
|
const isFirst = purchase.firstSuccessfulPayTimestamp === undefined;
|
2019-12-06 02:52:16 +01:00
|
|
|
purchase.firstSuccessfulPayTimestamp = getTimestampNow();
|
2019-12-10 12:22:29 +01:00
|
|
|
purchase.paymentSubmitPending = false;
|
2019-12-06 00:24:34 +01:00
|
|
|
purchase.lastPayError = undefined;
|
|
|
|
purchase.payRetryInfo = initRetryInfo(false);
|
2019-12-07 18:42:18 +01:00
|
|
|
if (isFirst) {
|
|
|
|
const ar = purchase.contractTerms.auto_refund;
|
|
|
|
if (ar) {
|
2019-12-07 22:02:11 +01:00
|
|
|
console.log("auto_refund present");
|
2019-12-07 18:42:18 +01:00
|
|
|
const autoRefundDelay = extractTalerDuration(ar);
|
2019-12-07 22:02:11 +01:00
|
|
|
console.log("auto_refund valid", autoRefundDelay);
|
2019-12-07 18:42:18 +01:00
|
|
|
if (autoRefundDelay) {
|
|
|
|
purchase.refundStatusRequested = true;
|
2019-12-07 22:02:11 +01:00
|
|
|
purchase.refundStatusRetryInfo = initRetryInfo();
|
|
|
|
purchase.lastRefundStatusError = undefined;
|
2019-12-07 18:42:18 +01:00
|
|
|
purchase.autoRefundDeadline = {
|
|
|
|
t_ms: getTimestampNow().t_ms + autoRefundDelay.d_ms,
|
2019-12-07 22:02:11 +01:00
|
|
|
};
|
2019-12-07 18:42:18 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-12-02 00:42:40 +01:00
|
|
|
const modifiedCoins: CoinRecord[] = [];
|
|
|
|
for (const pc of purchase.payReq.coins) {
|
2019-12-12 22:39:45 +01:00
|
|
|
const c = await ws.db.get(Stores.coins, pc.coin_pub);
|
2019-12-02 00:42:40 +01:00
|
|
|
if (!c) {
|
|
|
|
console.error("coin not found");
|
|
|
|
throw Error("coin used in payment not found");
|
|
|
|
}
|
2019-12-15 16:59:00 +01:00
|
|
|
c.status = CoinStatus.Dormant;
|
2019-12-02 00:42:40 +01:00
|
|
|
modifiedCoins.push(c);
|
|
|
|
}
|
|
|
|
|
2019-12-12 22:39:45 +01:00
|
|
|
await ws.db.runWithWriteTransaction(
|
2019-12-15 16:59:00 +01:00
|
|
|
[Stores.coins, Stores.purchases, Stores.refreshGroups],
|
2019-12-02 00:42:40 +01:00
|
|
|
async tx => {
|
|
|
|
for (let c of modifiedCoins) {
|
2019-12-03 00:52:15 +01:00
|
|
|
await tx.put(Stores.coins, c);
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
2019-12-15 19:04:14 +01:00
|
|
|
await createRefreshGroup(
|
|
|
|
tx,
|
|
|
|
modifiedCoins.map(x => ({ coinPub: x.coinPub })),
|
|
|
|
RefreshReason.Pay,
|
|
|
|
);
|
2019-12-03 00:52:15 +01:00
|
|
|
await tx.put(Stores.purchases, purchase);
|
2019-12-02 00:42:40 +01:00
|
|
|
},
|
|
|
|
);
|
|
|
|
|
|
|
|
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",
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2019-12-06 00:56:31 +01:00
|
|
|
let proposalId = await startDownloadProposal(
|
2019-12-03 00:52:15 +01:00
|
|
|
ws,
|
2019-12-06 12:47:28 +01:00
|
|
|
uriResult.merchantBaseUrl,
|
|
|
|
uriResult.orderId,
|
2019-12-03 00:52:15 +01:00
|
|
|
uriResult.sessionId,
|
2019-12-02 00:42:40 +01:00
|
|
|
);
|
|
|
|
|
2019-12-12 22:39:45 +01:00
|
|
|
let proposal = await ws.db.get(Stores.proposals, proposalId);
|
2019-12-03 00:52:15 +01:00
|
|
|
if (!proposal) {
|
|
|
|
throw Error(`could not get proposal ${proposalId}`);
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
2019-12-03 00:52:15 +01:00
|
|
|
if (proposal.proposalStatus === ProposalStatus.REPURCHASE) {
|
|
|
|
const existingProposalId = proposal.repurchaseProposalId;
|
|
|
|
if (!existingProposalId) {
|
|
|
|
throw Error("invalid proposal state");
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
2019-12-06 00:56:31 +01:00
|
|
|
console.log("using existing purchase for same product");
|
2019-12-12 22:39:45 +01:00
|
|
|
proposal = await ws.db.get(Stores.proposals, existingProposalId);
|
2019-12-03 00:52:15 +01:00
|
|
|
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");
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
|
|
|
|
2019-12-06 00:56:31 +01:00
|
|
|
proposalId = proposal.proposalId;
|
2019-12-03 00:52:15 +01:00
|
|
|
|
2019-12-02 00:42:40 +01:00
|
|
|
// First check if we already payed for it.
|
2019-12-12 22:39:45 +01:00
|
|
|
const purchase = await ws.db.get(Stores.purchases, proposalId);
|
2019-12-02 00:42:40 +01:00
|
|
|
|
|
|
|
if (!purchase) {
|
2019-12-03 00:52:15 +01:00
|
|
|
const paymentAmount = Amounts.parseOrThrow(contractTerms.amount);
|
2019-12-02 00:42:40 +01:00
|
|
|
let wireFeeLimit;
|
2019-12-03 00:52:15 +01:00
|
|
|
if (contractTerms.max_wire_fee) {
|
|
|
|
wireFeeLimit = Amounts.parseOrThrow(contractTerms.max_wire_fee);
|
2019-12-02 00:42:40 +01:00
|
|
|
} else {
|
|
|
|
wireFeeLimit = Amounts.getZero(paymentAmount.currency);
|
|
|
|
}
|
|
|
|
// If not already payed, check if we could pay for it.
|
|
|
|
const res = await getCoinsForPayment(ws, {
|
2019-12-03 00:52:15 +01:00
|
|
|
allowedAuditors: contractTerms.auditors,
|
|
|
|
allowedExchanges: contractTerms.exchanges,
|
|
|
|
depositFeeLimit: Amounts.parseOrThrow(contractTerms.max_fee),
|
2019-12-02 00:42:40 +01:00
|
|
|
paymentAmount,
|
2019-12-03 00:52:15 +01:00
|
|
|
wireFeeAmortization: contractTerms.wire_fee_amortization || 1,
|
2019-12-02 00:42:40 +01:00
|
|
|
wireFeeLimit,
|
2019-12-03 00:52:15 +01:00
|
|
|
wireFeeTime: extractTalerStampOrThrow(contractTerms.timestamp),
|
|
|
|
wireMethod: contractTerms.wire_method,
|
2019-12-02 00:42:40 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
if (!res) {
|
|
|
|
console.log("not confirming payment, insufficient coins");
|
|
|
|
return {
|
|
|
|
status: "insufficient-balance",
|
2019-12-03 00:52:15 +01:00
|
|
|
contractTerms: contractTerms,
|
2019-12-02 00:42:40 +01:00
|
|
|
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(
|
2019-12-03 00:52:15 +01:00
|
|
|
contractTerms,
|
2019-12-02 00:42:40 +01:00
|
|
|
cds,
|
|
|
|
totalAmount,
|
|
|
|
);
|
|
|
|
ws.speculativePayData = {
|
|
|
|
exchangeUrl,
|
|
|
|
payCoinInfo,
|
|
|
|
proposal,
|
|
|
|
orderDownloadId: proposalId,
|
|
|
|
};
|
|
|
|
logger.trace("created speculative pay data for payment");
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
status: "payment-possible",
|
2019-12-03 00:52:15 +01:00
|
|
|
contractTerms: contractTerms,
|
2019-12-02 00:42:40 +01:00
|
|
|
proposalId: proposal.proposalId,
|
|
|
|
totalFees: res.totalFees,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
if (uriResult.sessionId) {
|
2019-12-10 12:22:29 +01:00
|
|
|
await submitPay(ws, proposalId);
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
status: "paid",
|
2019-12-03 00:52:15 +01:00
|
|
|
contractTerms: purchase.contractTerms,
|
2019-12-02 00:42:40 +01:00
|
|
|
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) {
|
2019-12-12 22:39:45 +01:00
|
|
|
const cc = await ws.db.get(Stores.coins, coinKey);
|
2019-12-02 00:42:40 +01:00
|
|
|
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}`,
|
|
|
|
);
|
2019-12-12 22:39:45 +01:00
|
|
|
const proposal = await ws.db.get(Stores.proposals, proposalId);
|
2019-12-02 00:42:40 +01:00
|
|
|
|
|
|
|
if (!proposal) {
|
|
|
|
throw Error(`proposal with id ${proposalId} not found`);
|
|
|
|
}
|
|
|
|
|
2019-12-03 00:52:15 +01:00
|
|
|
const d = proposal.download;
|
|
|
|
if (!d) {
|
|
|
|
throw Error("proposal is in invalid state");
|
|
|
|
}
|
|
|
|
|
2019-12-12 22:39:45 +01:00
|
|
|
let purchase = await ws.db.get(Stores.purchases, d.contractTermsHash);
|
2019-12-02 00:42:40 +01:00
|
|
|
|
|
|
|
if (purchase) {
|
2019-12-10 12:22:29 +01:00
|
|
|
if (
|
|
|
|
sessionIdOverride !== undefined &&
|
|
|
|
sessionIdOverride != purchase.lastSessionId
|
|
|
|
) {
|
|
|
|
logger.trace(`changing session ID to ${sessionIdOverride}`);
|
2019-12-12 22:39:45 +01:00
|
|
|
await ws.db.mutate(Stores.purchases, purchase.proposalId, x => {
|
2019-12-10 12:22:29 +01:00
|
|
|
x.lastSessionId = sessionIdOverride;
|
|
|
|
x.paymentSubmitPending = true;
|
|
|
|
return x;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
logger.trace("confirmPay: submitting payment for existing purchase");
|
|
|
|
return submitPay(ws, proposalId);
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
|
|
|
|
2019-12-10 12:22:29 +01:00
|
|
|
logger.trace("confirmPay: purchase record does not exist yet");
|
|
|
|
|
2019-12-03 00:52:15 +01:00
|
|
|
const contractAmount = Amounts.parseOrThrow(d.contractTerms.amount);
|
2019-12-02 00:42:40 +01:00
|
|
|
|
|
|
|
let wireFeeLimit;
|
2019-12-03 00:52:15 +01:00
|
|
|
if (!d.contractTerms.max_wire_fee) {
|
2019-12-02 00:42:40 +01:00
|
|
|
wireFeeLimit = Amounts.getZero(contractAmount.currency);
|
|
|
|
} else {
|
2019-12-03 00:52:15 +01:00
|
|
|
wireFeeLimit = Amounts.parseOrThrow(d.contractTerms.max_wire_fee);
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
const res = await getCoinsForPayment(ws, {
|
2019-12-03 00:52:15 +01:00
|
|
|
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,
|
2019-12-02 00:42:40 +01:00
|
|
|
wireFeeLimit,
|
2019-12-03 00:52:15 +01:00
|
|
|
wireFeeTime: extractTalerStampOrThrow(d.contractTerms.timestamp),
|
|
|
|
wireMethod: d.contractTerms.wire_method,
|
2019-12-02 00:42:40 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
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(
|
2019-12-03 00:52:15 +01:00
|
|
|
d.contractTerms,
|
2019-12-02 00:42:40 +01:00
|
|
|
cds,
|
|
|
|
totalAmount,
|
|
|
|
);
|
2019-12-10 12:22:29 +01:00
|
|
|
purchase = await recordConfirmPay(
|
|
|
|
ws,
|
|
|
|
proposal,
|
|
|
|
payCoinInfo,
|
|
|
|
exchangeUrl,
|
|
|
|
sessionIdOverride,
|
|
|
|
);
|
2019-12-02 00:42:40 +01:00
|
|
|
} else {
|
|
|
|
purchase = await recordConfirmPay(
|
|
|
|
ws,
|
|
|
|
sd.proposal,
|
|
|
|
sd.payCoinInfo,
|
|
|
|
sd.exchangeUrl,
|
2019-12-10 12:22:29 +01:00
|
|
|
sessionIdOverride,
|
2019-12-02 00:42:40 +01:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2019-12-10 12:22:29 +01:00
|
|
|
logger.trace("confirmPay: submitting payment after creating purchase record");
|
|
|
|
return submitPay(ws, proposalId);
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
2019-12-03 14:40:05 +01:00
|
|
|
|
2019-12-06 00:24:34 +01:00
|
|
|
export async function processPurchasePay(
|
2019-12-05 19:38:19 +01:00
|
|
|
ws: InternalWalletState,
|
|
|
|
proposalId: string,
|
2019-12-07 22:02:11 +01:00
|
|
|
forceNow: boolean = false,
|
2019-12-05 19:38:19 +01:00
|
|
|
): Promise<void> {
|
|
|
|
const onOpErr = (e: OperationError) =>
|
2019-12-06 00:24:34 +01:00
|
|
|
incrementPurchasePayRetry(ws, proposalId, e);
|
2019-12-05 19:38:19 +01:00
|
|
|
await guardOperationException(
|
2019-12-07 22:02:11 +01:00
|
|
|
() => processPurchasePayImpl(ws, proposalId, forceNow),
|
2019-12-05 19:38:19 +01:00
|
|
|
onOpErr,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2019-12-07 22:02:11 +01:00
|
|
|
async function resetPurchasePayRetry(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
proposalId: string,
|
|
|
|
) {
|
2019-12-12 22:39:45 +01:00
|
|
|
await ws.db.mutate(Stores.purchases, proposalId, x => {
|
2019-12-07 22:02:11 +01:00
|
|
|
if (x.payRetryInfo.active) {
|
|
|
|
x.payRetryInfo = initRetryInfo();
|
|
|
|
}
|
|
|
|
return x;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2019-12-06 00:24:34 +01:00
|
|
|
async function processPurchasePayImpl(
|
2019-12-05 19:38:19 +01:00
|
|
|
ws: InternalWalletState,
|
|
|
|
proposalId: string,
|
2019-12-07 22:02:11 +01:00
|
|
|
forceNow: boolean,
|
2019-12-05 19:38:19 +01:00
|
|
|
): Promise<void> {
|
2019-12-07 22:02:11 +01:00
|
|
|
if (forceNow) {
|
|
|
|
await resetPurchasePayRetry(ws, proposalId);
|
|
|
|
}
|
2019-12-12 22:39:45 +01:00
|
|
|
const purchase = await ws.db.get(Stores.purchases, proposalId);
|
2019-12-05 19:38:19 +01:00
|
|
|
if (!purchase) {
|
|
|
|
return;
|
|
|
|
}
|
2019-12-10 12:22:29 +01:00
|
|
|
if (!purchase.paymentSubmitPending) {
|
2019-12-06 00:24:34 +01:00
|
|
|
return;
|
|
|
|
}
|
2019-12-10 12:22:29 +01:00
|
|
|
logger.trace(`processing purchase pay ${proposalId}`);
|
|
|
|
await submitPay(ws, proposalId);
|
2019-12-06 00:24:34 +01:00
|
|
|
}
|