2019-12-02 00:42:40 +01:00
|
|
|
/*
|
|
|
|
This file is part of GNU Taler
|
2019-12-25 21:47:57 +01:00
|
|
|
(C) 2019 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 {
|
|
|
|
CoinStatus,
|
|
|
|
initRetryInfo,
|
|
|
|
ProposalRecord,
|
|
|
|
ProposalStatus,
|
|
|
|
PurchaseRecord,
|
|
|
|
Stores,
|
|
|
|
updateRetryInfoTimeout,
|
2019-12-15 21:40:06 +01:00
|
|
|
PayEventRecord,
|
2019-12-19 20:42:49 +01:00
|
|
|
WalletContractData,
|
2019-12-15 19:08:07 +01:00
|
|
|
} from "../types/dbTypes";
|
|
|
|
import { NotificationType } from "../types/notifications";
|
2019-12-02 00:42:40 +01:00
|
|
|
import {
|
2019-12-19 20:42:49 +01:00
|
|
|
codecForProposal,
|
|
|
|
codecForContractTerms,
|
2019-12-25 19:11:20 +01:00
|
|
|
CoinDepositPermission,
|
2020-07-21 08:53:48 +02:00
|
|
|
codecForMerchantPayResponse,
|
2019-12-12 20:53:15 +01:00
|
|
|
} from "../types/talerTypes";
|
2019-12-02 00:42:40 +01:00
|
|
|
import {
|
|
|
|
ConfirmPayResult,
|
2020-07-22 10:52:03 +02:00
|
|
|
OperationErrorDetails,
|
2019-12-15 19:08:07 +01:00
|
|
|
PreparePayResult,
|
2019-12-15 16:59:00 +01:00
|
|
|
RefreshReason,
|
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 { Logger } from "../util/logging";
|
2020-07-20 14:16:49 +02:00
|
|
|
import { 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";
|
|
|
|
import { InternalWalletState } from "./state";
|
2019-12-25 19:11:20 +01:00
|
|
|
import { getTimestampNow, timestampAddDuration } from "../util/time";
|
|
|
|
import { strcmp, canonicalJson } from "../util/helpers";
|
2020-07-22 10:52:03 +02:00
|
|
|
import {
|
|
|
|
readSuccessResponseJsonOrThrow,
|
|
|
|
} from "../util/http";
|
2019-12-02 00:42:40 +01:00
|
|
|
|
2019-12-25 21:47:57 +01:00
|
|
|
/**
|
|
|
|
* Logger.
|
|
|
|
*/
|
|
|
|
const logger = new Logger("pay.ts");
|
|
|
|
|
2019-12-25 19:11:20 +01:00
|
|
|
/**
|
|
|
|
* Result of selecting coins, contains the exchange, and selected
|
|
|
|
* coins with their denomination.
|
|
|
|
*/
|
|
|
|
export interface PayCoinSelection {
|
|
|
|
/**
|
|
|
|
* Amount requested by the merchant.
|
|
|
|
*/
|
2019-12-02 00:42:40 +01:00
|
|
|
paymentAmount: AmountJson;
|
2019-12-25 19:11:20 +01:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Public keys of the coins that were selected.
|
|
|
|
*/
|
|
|
|
coinPubs: string[];
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Amount that each coin contributes.
|
|
|
|
*/
|
|
|
|
coinContributions: AmountJson[];
|
|
|
|
|
|
|
|
/**
|
|
|
|
* How much of the wire fees is the customer paying?
|
|
|
|
*/
|
|
|
|
customerWireFees: AmountJson;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* How much of the deposit fees is the customer paying?
|
|
|
|
*/
|
|
|
|
customerDepositFees: AmountJson;
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
|
|
|
|
2019-12-25 21:47:57 +01:00
|
|
|
/**
|
|
|
|
* Structure to describe a coin that is available to be
|
|
|
|
* used in a payment.
|
|
|
|
*/
|
2019-12-25 19:11:20 +01:00
|
|
|
export interface AvailableCoinInfo {
|
2019-12-25 21:47:57 +01:00
|
|
|
/**
|
|
|
|
* Public key of the coin.
|
|
|
|
*/
|
2019-12-25 19:11:20 +01:00
|
|
|
coinPub: string;
|
2019-12-25 21:47:57 +01:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Coin's denomination public key.
|
|
|
|
*/
|
2019-12-25 19:11:20 +01:00
|
|
|
denomPub: string;
|
2019-12-25 21:47:57 +01:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Amount still remaining (typically the full amount,
|
|
|
|
* as coins are always refreshed after use.)
|
|
|
|
*/
|
2019-12-25 19:11:20 +01:00
|
|
|
availableAmount: AmountJson;
|
2019-12-25 21:47:57 +01:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Deposit fee for the coin.
|
|
|
|
*/
|
2019-12-25 19:11:20 +01:00
|
|
|
feeDeposit: AmountJson;
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
|
|
|
|
2020-05-12 12:14:48 +02:00
|
|
|
export interface PayCostInfo {
|
|
|
|
totalCost: AmountJson;
|
|
|
|
}
|
|
|
|
|
2019-12-02 00:42:40 +01:00
|
|
|
/**
|
2019-12-25 19:11:20 +01:00
|
|
|
* Compute the total cost of a payment to the customer.
|
2020-03-30 12:39:32 +02:00
|
|
|
*
|
2019-12-25 21:47:57 +01:00
|
|
|
* This includes the amount taken by the merchant, fees (wire/deposit) contributed
|
|
|
|
* by the customer, refreshing fees, fees for withdraw-after-refresh and "trimmings"
|
|
|
|
* of coins that are too small to spend.
|
2019-12-25 19:11:20 +01:00
|
|
|
*/
|
|
|
|
export async function getTotalPaymentCost(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
pcs: PayCoinSelection,
|
2020-05-12 12:14:48 +02:00
|
|
|
): Promise<PayCostInfo> {
|
2020-05-15 12:33:52 +02:00
|
|
|
const costs = [];
|
2019-12-25 19:11:20 +01:00
|
|
|
for (let i = 0; i < pcs.coinPubs.length; i++) {
|
|
|
|
const coin = await ws.db.get(Stores.coins, pcs.coinPubs[i]);
|
|
|
|
if (!coin) {
|
|
|
|
throw Error("can't calculate payment cost, coin not found");
|
|
|
|
}
|
|
|
|
const denom = await ws.db.get(Stores.denominations, [
|
|
|
|
coin.exchangeBaseUrl,
|
|
|
|
coin.denomPub,
|
|
|
|
]);
|
|
|
|
if (!denom) {
|
|
|
|
throw Error(
|
|
|
|
"can't calculate payment cost, denomination for coin not found",
|
|
|
|
);
|
|
|
|
}
|
|
|
|
const allDenoms = await ws.db
|
|
|
|
.iterIndex(
|
|
|
|
Stores.denominations.exchangeBaseUrlIndex,
|
|
|
|
coin.exchangeBaseUrl,
|
|
|
|
)
|
|
|
|
.toArray();
|
|
|
|
const amountLeft = Amounts.sub(denom.value, pcs.coinContributions[i])
|
|
|
|
.amount;
|
|
|
|
const refreshCost = getTotalRefreshCost(allDenoms, denom, amountLeft);
|
2020-05-15 12:33:52 +02:00
|
|
|
costs.push(pcs.coinContributions[i]);
|
2019-12-25 19:11:20 +01:00
|
|
|
costs.push(refreshCost);
|
|
|
|
}
|
2020-05-12 12:14:48 +02:00
|
|
|
return {
|
2020-06-03 13:16:25 +02:00
|
|
|
totalCost: Amounts.sum(costs).amount,
|
2020-05-12 12:14:48 +02:00
|
|
|
};
|
2019-12-25 19:11:20 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Given a list of available coins, select coins to spend under the merchant's
|
|
|
|
* constraints.
|
|
|
|
*
|
|
|
|
* This function is only exported for the sake of unit tests.
|
2019-12-02 00:42:40 +01:00
|
|
|
*/
|
|
|
|
export function selectPayCoins(
|
2019-12-25 19:11:20 +01:00
|
|
|
acis: AvailableCoinInfo[],
|
2020-05-15 19:53:49 +02:00
|
|
|
contractTermsAmount: AmountJson,
|
|
|
|
customerWireFees: AmountJson,
|
2019-12-02 00:42:40 +01:00
|
|
|
depositFeeLimit: AmountJson,
|
2019-12-25 19:11:20 +01:00
|
|
|
): PayCoinSelection | undefined {
|
|
|
|
if (acis.length === 0) {
|
2019-12-02 00:42:40 +01:00
|
|
|
return undefined;
|
|
|
|
}
|
2019-12-25 19:11:20 +01:00
|
|
|
const coinPubs: string[] = [];
|
|
|
|
const coinContributions: AmountJson[] = [];
|
2020-01-19 21:17:39 +01:00
|
|
|
// Sort by available amount (descending), deposit fee (ascending) and
|
|
|
|
// denomPub (ascending) if deposit fee is the same
|
2019-12-02 00:42:40 +01:00
|
|
|
// (to guarantee deterministic results)
|
2019-12-25 19:11:20 +01:00
|
|
|
acis.sort(
|
2019-12-02 00:42:40 +01:00
|
|
|
(o1, o2) =>
|
2020-01-19 21:17:39 +01:00
|
|
|
-Amounts.cmp(o1.availableAmount, o2.availableAmount) ||
|
2019-12-25 19:11:20 +01:00
|
|
|
Amounts.cmp(o1.feeDeposit, o2.feeDeposit) ||
|
|
|
|
strcmp(o1.denomPub, o2.denomPub),
|
2019-12-02 00:42:40 +01:00
|
|
|
);
|
2020-06-03 13:16:25 +02:00
|
|
|
const paymentAmount = Amounts.add(contractTermsAmount, customerWireFees)
|
|
|
|
.amount;
|
2019-12-25 19:11:20 +01:00
|
|
|
const currency = paymentAmount.currency;
|
|
|
|
let amountPayRemaining = paymentAmount;
|
|
|
|
let amountDepositFeeLimitRemaining = depositFeeLimit;
|
2020-04-06 17:45:41 +02:00
|
|
|
const customerDepositFees = Amounts.getZero(currency);
|
2019-12-25 19:11:20 +01:00
|
|
|
for (const aci of acis) {
|
|
|
|
// Don't use this coin if depositing it is more expensive than
|
|
|
|
// the amount it would give the merchant.
|
|
|
|
if (Amounts.cmp(aci.feeDeposit, aci.availableAmount) >= 0) {
|
2019-12-02 00:42:40 +01:00
|
|
|
continue;
|
|
|
|
}
|
2019-12-25 19:11:20 +01:00
|
|
|
if (amountPayRemaining.value === 0 && amountPayRemaining.fraction === 0) {
|
|
|
|
// We have spent enough!
|
|
|
|
break;
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
2019-12-25 19:11:20 +01:00
|
|
|
|
|
|
|
// How much does the user spend on deposit fees for this coin?
|
|
|
|
const depositFeeSpend = Amounts.sub(
|
|
|
|
aci.feeDeposit,
|
|
|
|
amountDepositFeeLimitRemaining,
|
|
|
|
).amount;
|
|
|
|
|
|
|
|
if (Amounts.isZero(depositFeeSpend)) {
|
|
|
|
// Fees are still covered by the merchant.
|
|
|
|
amountDepositFeeLimitRemaining = Amounts.sub(
|
|
|
|
amountDepositFeeLimitRemaining,
|
|
|
|
aci.feeDeposit,
|
|
|
|
).amount;
|
|
|
|
} else {
|
|
|
|
amountDepositFeeLimitRemaining = Amounts.getZero(currency);
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
2019-12-25 19:11:20 +01:00
|
|
|
|
|
|
|
let coinSpend: AmountJson;
|
|
|
|
const amountActualAvailable = Amounts.sub(
|
|
|
|
aci.availableAmount,
|
|
|
|
depositFeeSpend,
|
2019-12-02 00:42:40 +01:00
|
|
|
).amount;
|
|
|
|
|
2019-12-25 19:11:20 +01:00
|
|
|
if (Amounts.cmp(amountActualAvailable, amountPayRemaining) > 0) {
|
2020-03-27 10:50:02 +01:00
|
|
|
// Partial spending, as the coin is worth more than the remaining
|
|
|
|
// amount to pay.
|
2019-12-25 19:11:20 +01:00
|
|
|
coinSpend = Amounts.add(amountPayRemaining, depositFeeSpend).amount;
|
2020-03-27 10:50:02 +01:00
|
|
|
// Make sure we contribute at least the deposit fee, otherwise
|
|
|
|
// contributing this coin would cause a loss for the merchant.
|
|
|
|
if (Amounts.cmp(coinSpend, aci.feeDeposit) < 0) {
|
|
|
|
coinSpend = aci.feeDeposit;
|
|
|
|
}
|
2019-12-25 19:11:20 +01:00
|
|
|
amountPayRemaining = Amounts.getZero(currency);
|
|
|
|
} else {
|
2020-03-27 10:50:02 +01:00
|
|
|
// Spend the full remaining amount on the coin
|
2019-12-25 19:11:20 +01:00
|
|
|
coinSpend = aci.availableAmount;
|
|
|
|
amountPayRemaining = Amounts.add(amountPayRemaining, depositFeeSpend)
|
|
|
|
.amount;
|
|
|
|
amountPayRemaining = Amounts.sub(amountPayRemaining, aci.availableAmount)
|
2019-12-02 00:42:40 +01:00
|
|
|
.amount;
|
|
|
|
}
|
2019-12-25 19:11:20 +01:00
|
|
|
|
|
|
|
coinPubs.push(aci.coinPub);
|
|
|
|
coinContributions.push(coinSpend);
|
|
|
|
}
|
|
|
|
if (Amounts.isZero(amountPayRemaining)) {
|
|
|
|
return {
|
2020-05-15 19:53:49 +02:00
|
|
|
paymentAmount: contractTermsAmount,
|
2019-12-25 19:11:20 +01:00
|
|
|
coinContributions,
|
|
|
|
coinPubs,
|
|
|
|
customerDepositFees,
|
|
|
|
customerWireFees,
|
|
|
|
};
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2019-12-25 19:11:20 +01:00
|
|
|
* Select coins from the wallet's database that can be used
|
|
|
|
* to pay for the given contract.
|
|
|
|
*
|
|
|
|
* If payment is impossible, undefined is returned.
|
2019-12-02 00:42:40 +01:00
|
|
|
*/
|
|
|
|
async function getCoinsForPayment(
|
|
|
|
ws: InternalWalletState,
|
2019-12-25 19:11:20 +01:00
|
|
|
contractData: WalletContractData,
|
|
|
|
): Promise<PayCoinSelection | undefined> {
|
2020-05-15 19:53:49 +02:00
|
|
|
const remainingAmount = contractData.amount;
|
2019-12-02 00:42:40 +01:00
|
|
|
|
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) {
|
2020-04-06 17:45:41 +02:00
|
|
|
let isOkay = false;
|
2019-12-02 00:42:40 +01:00
|
|
|
const exchangeDetails = exchange.details;
|
|
|
|
if (!exchangeDetails) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
const exchangeFees = exchange.wireInfo;
|
|
|
|
if (!exchangeFees) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
// is the exchange explicitly allowed?
|
2019-12-25 19:11:20 +01:00
|
|
|
for (const allowedExchange of contractData.allowedExchanges) {
|
2019-12-19 20:42:49 +01:00
|
|
|
if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) {
|
2019-12-02 00:42:40 +01:00
|
|
|
isOkay = true;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// is the exchange allowed because of one of its auditors?
|
|
|
|
if (!isOkay) {
|
2019-12-25 19:11:20 +01:00
|
|
|
for (const allowedAuditor of contractData.allowedAuditors) {
|
2019-12-02 00:42:40 +01:00
|
|
|
for (const auditor of exchangeDetails.auditors) {
|
2019-12-19 20:42:49 +01:00
|
|
|
if (auditor.auditor_pub === allowedAuditor.auditorPub) {
|
2019-12-02 00:42:40 +01:00
|
|
|
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
|
|
|
|
|
|
|
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;
|
2019-12-25 19:11:20 +01:00
|
|
|
const acis: AvailableCoinInfo[] = [];
|
2019-12-02 00:42:40 +01:00
|
|
|
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;
|
|
|
|
}
|
2019-12-25 19:11:20 +01:00
|
|
|
acis.push({
|
|
|
|
availableAmount: coin.currentAmount,
|
|
|
|
coinPub: coin.coinPub,
|
|
|
|
denomPub: coin.denomPub,
|
|
|
|
feeDeposit: denom.feeDeposit,
|
|
|
|
});
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
let wireFee: AmountJson | undefined;
|
2019-12-25 19:11:20 +01:00
|
|
|
for (const fee of exchangeFees.feesForType[contractData.wireMethod] || []) {
|
|
|
|
if (
|
|
|
|
fee.startStamp <= contractData.timestamp &&
|
|
|
|
fee.endStamp >= contractData.timestamp
|
|
|
|
) {
|
2019-12-02 00:42:40 +01:00
|
|
|
wireFee = fee.wireFee;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-05-15 19:53:49 +02:00
|
|
|
let customerWireFee: AmountJson;
|
|
|
|
|
2019-12-02 00:42:40 +01:00
|
|
|
if (wireFee) {
|
2019-12-25 19:11:20 +01:00
|
|
|
const amortizedWireFee = Amounts.divide(
|
|
|
|
wireFee,
|
|
|
|
contractData.wireFeeAmortization,
|
|
|
|
);
|
|
|
|
if (Amounts.cmp(contractData.maxWireFee, amortizedWireFee) < 0) {
|
2020-05-15 19:53:49 +02:00
|
|
|
customerWireFee = amortizedWireFee;
|
|
|
|
} else {
|
|
|
|
customerWireFee = Amounts.getZero(currency);
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
2020-05-15 19:53:49 +02:00
|
|
|
} else {
|
|
|
|
customerWireFee = Amounts.getZero(currency);
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
|
|
|
|
2019-12-25 19:11:20 +01:00
|
|
|
// Try if paying using this exchange works
|
|
|
|
const res = selectPayCoins(
|
|
|
|
acis,
|
|
|
|
remainingAmount,
|
2020-05-15 19:53:49 +02:00
|
|
|
customerWireFee,
|
2019-12-25 19:11:20 +01:00
|
|
|
contractData.maxDepositFee,
|
|
|
|
);
|
2019-12-02 00:42:40 +01:00
|
|
|
if (res) {
|
2019-12-25 19:11:20 +01:00
|
|
|
return res;
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
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,
|
2019-12-25 19:11:20 +01:00
|
|
|
coinSelection: PayCoinSelection,
|
|
|
|
coinDepositPermissions: CoinDepositPermission[],
|
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}`);
|
2020-05-12 12:34:28 +02:00
|
|
|
const payCostInfo = await getTotalPaymentCost(ws, coinSelection);
|
2019-12-02 00:42:40 +01:00
|
|
|
const t: PurchaseRecord = {
|
|
|
|
abortDone: false,
|
|
|
|
abortRequested: false,
|
2019-12-19 20:42:49 +01:00
|
|
|
contractTermsRaw: d.contractTermsRaw,
|
|
|
|
contractData: d.contractData,
|
2019-12-10 12:22:29 +01:00
|
|
|
lastSessionId: sessionId,
|
2020-05-12 12:14:48 +02:00
|
|
|
payCoinSelection: coinSelection,
|
2020-05-12 12:34:28 +02:00
|
|
|
payCostInfo,
|
2020-07-21 08:53:48 +02:00
|
|
|
coinDepositPermissions,
|
2019-12-16 16:20:45 +01:00
|
|
|
timestampAccept: getTimestampNow(),
|
|
|
|
timestampLastRefundStatus: 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,
|
2019-12-16 16:20:45 +01:00
|
|
|
timestampFirstSuccessfulPay: undefined,
|
2019-12-07 18:42:18 +01:00
|
|
|
autoRefundDeadline: undefined,
|
2019-12-10 12:22:29 +01:00
|
|
|
paymentSubmitPending: true,
|
2020-07-23 14:05:17 +02:00
|
|
|
refunds: {},
|
2019-12-02 00:42:40 +01:00
|
|
|
};
|
|
|
|
|
2019-12-12 22:39:45 +01:00
|
|
|
await ws.db.runWithWriteTransaction(
|
2019-12-15 21:40:06 +01:00
|
|
|
[Stores.coins, Stores.purchases, Stores.proposals, Stores.refreshGroups],
|
2020-03-30 12:39:32 +02: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);
|
2019-12-25 19:11:20 +01:00
|
|
|
for (let i = 0; i < coinSelection.coinPubs.length; i++) {
|
|
|
|
const coin = await tx.get(Stores.coins, coinSelection.coinPubs[i]);
|
2019-12-15 21:40:06 +01:00
|
|
|
if (!coin) {
|
|
|
|
throw Error("coin allocated for payment doesn't exist anymore");
|
|
|
|
}
|
|
|
|
coin.status = CoinStatus.Dormant;
|
2019-12-19 20:42:49 +01:00
|
|
|
const remaining = Amounts.sub(
|
|
|
|
coin.currentAmount,
|
2019-12-25 19:11:20 +01:00
|
|
|
coinSelection.coinContributions[i],
|
2019-12-19 20:42:49 +01:00
|
|
|
);
|
2019-12-15 21:40:06 +01:00
|
|
|
if (remaining.saturated) {
|
|
|
|
throw Error("not enough remaining balance on coin for payment");
|
|
|
|
}
|
|
|
|
coin.currentAmount = remaining.amount;
|
|
|
|
await tx.put(Stores.coins, coin);
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
2020-03-30 12:39:32 +02:00
|
|
|
const refreshCoinPubs = coinSelection.coinPubs.map((x) => ({
|
|
|
|
coinPub: x,
|
|
|
|
}));
|
2020-07-23 14:05:17 +02:00
|
|
|
await createRefreshGroup(ws, tx, refreshCoinPubs, RefreshReason.Pay);
|
2019-12-02 00:42:40 +01:00
|
|
|
},
|
|
|
|
);
|
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2019-12-19 20:42:49 +01:00
|
|
|
function getNextUrl(contractData: WalletContractData): string {
|
|
|
|
const f = contractData.fulfillmentUrl;
|
2019-12-02 17:35:47 +01:00
|
|
|
if (f.startsWith("http://") || f.startsWith("https://")) {
|
2019-12-19 20:42:49 +01:00
|
|
|
const fu = new URL(contractData.fulfillmentUrl);
|
|
|
|
fu.searchParams.set("order_id", contractData.orderId);
|
2019-12-02 17:35:47 +01:00
|
|
|
return fu.href;
|
|
|
|
} else {
|
|
|
|
return f;
|
|
|
|
}
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
|
|
|
|
2019-12-05 19:38:19 +01:00
|
|
|
async function incrementProposalRetry(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
proposalId: string,
|
2020-07-22 10:52:03 +02:00
|
|
|
err: OperationErrorDetails | undefined,
|
2019-12-05 19:38:19 +01:00
|
|
|
): Promise<void> {
|
2020-03-30 12:39:32 +02: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);
|
|
|
|
});
|
2020-07-20 14:16:49 +02:00
|
|
|
if (err) {
|
|
|
|
ws.notify({ type: NotificationType.ProposalOperationError, error: err });
|
|
|
|
}
|
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,
|
2020-07-22 10:52:03 +02:00
|
|
|
err: OperationErrorDetails | undefined,
|
2019-12-05 19:38:19 +01:00
|
|
|
): Promise<void> {
|
2019-12-06 00:24:34 +01:00
|
|
|
console.log("incrementing purchase pay retry with error", err);
|
2020-03-30 12:39:32 +02: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);
|
|
|
|
});
|
2020-07-22 10:52:03 +02:00
|
|
|
if (err) {
|
|
|
|
ws.notify({ type: NotificationType.PayOperationError, error: err });
|
|
|
|
}
|
2019-12-06 00:24:34 +01:00
|
|
|
}
|
|
|
|
|
2019-12-03 00:52:15 +01:00
|
|
|
export async function processDownloadProposal(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
proposalId: string,
|
2020-04-06 17:45:41 +02:00
|
|
|
forceNow = false,
|
2019-12-05 19:38:19 +01:00
|
|
|
): Promise<void> {
|
2020-07-22 10:52:03 +02:00
|
|
|
const onOpErr = (err: OperationErrorDetails): Promise<void> =>
|
2019-12-05 19:38:19 +01:00
|
|
|
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,
|
2020-04-06 20:02:01 +02:00
|
|
|
): Promise<void> {
|
2020-03-30 12:39:32 +02: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
|
|
|
|
2020-07-20 14:16:49 +02:00
|
|
|
const orderClaimUrl = new URL(
|
|
|
|
`orders/${proposal.orderId}/claim`,
|
|
|
|
proposal.merchantBaseUrl,
|
|
|
|
).href;
|
|
|
|
logger.trace("downloading contract from '" + orderClaimUrl + "'");
|
|
|
|
|
2020-07-22 10:52:03 +02:00
|
|
|
const reqestBody = {
|
|
|
|
nonce: proposal.noncePub,
|
|
|
|
};
|
|
|
|
|
|
|
|
const resp = await ws.http.postJson(orderClaimUrl, reqestBody);
|
|
|
|
const proposalResp = await readSuccessResponseJsonOrThrow(
|
|
|
|
resp,
|
|
|
|
codecForProposal(),
|
|
|
|
);
|
2019-12-09 13:29:11 +01:00
|
|
|
|
2020-07-20 14:16:49 +02:00
|
|
|
// The proposalResp contains the contract terms as raw JSON,
|
|
|
|
// as the coded to parse them doesn't necessarily round-trip.
|
|
|
|
// We need this raw JSON to compute the contract terms hash.
|
2019-12-03 00:52:15 +01:00
|
|
|
|
|
|
|
const contractTermsHash = await ws.cryptoApi.hashString(
|
|
|
|
canonicalJson(proposalResp.contract_terms),
|
|
|
|
);
|
|
|
|
|
2019-12-19 20:42:49 +01:00
|
|
|
const parsedContractTerms = codecForContractTerms().decode(
|
|
|
|
proposalResp.contract_terms,
|
|
|
|
);
|
|
|
|
const fulfillmentUrl = parsedContractTerms.fulfillment_url;
|
2019-12-03 00:52:15 +01:00
|
|
|
|
2019-12-12 22:39:45 +01:00
|
|
|
await ws.db.runWithWriteTransaction(
|
2019-12-03 00:52:15 +01:00
|
|
|
[Stores.proposals, Stores.purchases],
|
2020-03-30 12:39:32 +02:00
|
|
|
async (tx) => {
|
2019-12-03 00:52:15 +01:00
|
|
|
const p = await tx.get(Stores.proposals, proposalId);
|
|
|
|
if (!p) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (p.proposalStatus !== ProposalStatus.DOWNLOADING) {
|
|
|
|
return;
|
|
|
|
}
|
2019-12-19 20:42:49 +01:00
|
|
|
const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
|
|
|
|
let maxWireFee: AmountJson;
|
|
|
|
if (parsedContractTerms.max_wire_fee) {
|
|
|
|
maxWireFee = Amounts.parseOrThrow(parsedContractTerms.max_wire_fee);
|
|
|
|
} else {
|
|
|
|
maxWireFee = Amounts.getZero(amount.currency);
|
|
|
|
}
|
2019-12-16 22:42:10 +01:00
|
|
|
p.download = {
|
2019-12-19 20:42:49 +01:00
|
|
|
contractData: {
|
|
|
|
amount,
|
|
|
|
contractTermsHash: contractTermsHash,
|
|
|
|
fulfillmentUrl: parsedContractTerms.fulfillment_url,
|
|
|
|
merchantBaseUrl: parsedContractTerms.merchant_base_url,
|
|
|
|
merchantPub: parsedContractTerms.merchant_pub,
|
|
|
|
merchantSig: proposalResp.sig,
|
|
|
|
orderId: parsedContractTerms.order_id,
|
|
|
|
summary: parsedContractTerms.summary,
|
|
|
|
autoRefund: parsedContractTerms.auto_refund,
|
|
|
|
maxWireFee,
|
|
|
|
payDeadline: parsedContractTerms.pay_deadline,
|
|
|
|
refundDeadline: parsedContractTerms.refund_deadline,
|
|
|
|
wireFeeAmortization: parsedContractTerms.wire_fee_amortization || 1,
|
2020-03-30 12:39:32 +02:00
|
|
|
allowedAuditors: parsedContractTerms.auditors.map((x) => ({
|
2019-12-19 20:42:49 +01:00
|
|
|
auditorBaseUrl: x.url,
|
|
|
|
auditorPub: x.master_pub,
|
|
|
|
})),
|
2020-03-30 12:39:32 +02:00
|
|
|
allowedExchanges: parsedContractTerms.exchanges.map((x) => ({
|
2019-12-19 20:42:49 +01:00
|
|
|
exchangeBaseUrl: x.url,
|
|
|
|
exchangePub: x.master_pub,
|
|
|
|
})),
|
|
|
|
timestamp: parsedContractTerms.timestamp,
|
|
|
|
wireMethod: parsedContractTerms.wire_method,
|
2020-01-17 21:59:47 +01:00
|
|
|
wireInfoHash: parsedContractTerms.h_wire,
|
2019-12-19 20:42:49 +01:00
|
|
|
maxDepositFee: Amounts.parseOrThrow(parsedContractTerms.max_fee),
|
2020-05-15 12:33:52 +02:00
|
|
|
merchant: parsedContractTerms.merchant,
|
|
|
|
products: parsedContractTerms.products,
|
|
|
|
summaryI18n: parsedContractTerms.summary_i18n,
|
2019-12-19 20:42:49 +01:00
|
|
|
},
|
|
|
|
contractTermsRaw: JSON.stringify(proposalResp.contract_terms),
|
2019-12-16 22:42:10 +01:00
|
|
|
};
|
2019-12-03 00:52:15 +01:00
|
|
|
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.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
|
|
|
};
|
|
|
|
|
2020-03-30 12:39:32 +02:00
|
|
|
await ws.db.runWithWriteTransaction([Stores.proposals], async (tx) => {
|
2019-12-15 19:04:14 +01:00
|
|
|
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
|
|
|
|
2019-12-10 12:22:29 +01:00
|
|
|
console.log("paying with session ID", sessionId);
|
|
|
|
|
2020-07-21 08:53:48 +02:00
|
|
|
const payUrl = new URL(
|
|
|
|
`orders/${purchase.contractData.orderId}/pay`,
|
|
|
|
purchase.contractData.merchantBaseUrl,
|
|
|
|
).href;
|
|
|
|
|
2020-07-22 10:52:03 +02:00
|
|
|
const reqBody = {
|
|
|
|
coins: purchase.coinDepositPermissions,
|
|
|
|
session_id: purchase.lastSessionId,
|
|
|
|
};
|
2020-07-23 20:52:46 +02:00
|
|
|
|
|
|
|
logger.trace("making pay request", JSON.stringify(reqBody, undefined, 2));
|
2020-07-22 10:52:03 +02:00
|
|
|
|
|
|
|
const resp = await ws.http.postJson(payUrl, reqBody);
|
|
|
|
|
|
|
|
const merchantResp = await readSuccessResponseJsonOrThrow(
|
|
|
|
resp,
|
|
|
|
codecForMerchantPayResponse(),
|
|
|
|
);
|
2019-12-02 00:42:40 +01:00
|
|
|
|
2020-07-23 20:52:46 +02:00
|
|
|
logger.trace("got success from pay URL", merchantResp);
|
2019-12-02 00:42:40 +01:00
|
|
|
|
2019-12-15 21:40:06 +01:00
|
|
|
const now = getTimestampNow();
|
|
|
|
|
2019-12-19 20:42:49 +01:00
|
|
|
const merchantPub = purchase.contractData.merchantPub;
|
2019-12-02 00:42:40 +01:00
|
|
|
const valid: boolean = await ws.cryptoApi.isValidPaymentSignature(
|
|
|
|
merchantResp.sig,
|
2019-12-19 20:42:49 +01:00
|
|
|
purchase.contractData.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-16 16:20:45 +01:00
|
|
|
const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
|
|
|
|
purchase.timestampFirstSuccessfulPay = now;
|
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) {
|
2019-12-19 20:42:49 +01:00
|
|
|
const ar = purchase.contractData.autoRefund;
|
2019-12-07 18:42:18 +01:00
|
|
|
if (ar) {
|
2019-12-07 22:02:11 +01:00
|
|
|
console.log("auto_refund present");
|
2019-12-19 20:42:49 +01:00
|
|
|
purchase.refundStatusRequested = true;
|
|
|
|
purchase.refundStatusRetryInfo = initRetryInfo();
|
|
|
|
purchase.lastRefundStatusError = undefined;
|
|
|
|
purchase.autoRefundDeadline = timestampAddDuration(now, ar);
|
2019-12-07 18:42:18 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-12-12 22:39:45 +01:00
|
|
|
await ws.db.runWithWriteTransaction(
|
2019-12-15 21:40:06 +01:00
|
|
|
[Stores.purchases, Stores.payEvents],
|
2020-03-30 12:39:32 +02:00
|
|
|
async (tx) => {
|
2019-12-03 00:52:15 +01:00
|
|
|
await tx.put(Stores.purchases, purchase);
|
2019-12-15 21:40:06 +01:00
|
|
|
const payEvent: PayEventRecord = {
|
|
|
|
proposalId,
|
|
|
|
sessionId,
|
|
|
|
timestamp: now,
|
2019-12-16 12:53:22 +01:00
|
|
|
isReplay: !isFirst,
|
2019-12-15 21:40:06 +01:00
|
|
|
};
|
|
|
|
await tx.put(Stores.payEvents, payEvent);
|
2019-12-02 00:42:40 +01:00
|
|
|
},
|
|
|
|
);
|
|
|
|
|
2019-12-19 20:42:49 +01:00
|
|
|
const nextUrl = getNextUrl(purchase.contractData);
|
|
|
|
ws.cachedNextUrl[purchase.contractData.fulfillmentUrl] = {
|
2019-12-02 00:42:40 +01:00
|
|
|
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.
|
|
|
|
*/
|
2019-12-20 01:25:22 +01:00
|
|
|
export async function preparePayForUri(
|
2019-12-02 00:42:40 +01:00
|
|
|
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");
|
|
|
|
}
|
2019-12-19 20:42:49 +01:00
|
|
|
const contractData = d.contractData;
|
|
|
|
const merchantSig = d.contractData.merchantSig;
|
|
|
|
if (!merchantSig) {
|
2019-12-03 00:52:15 +01:00
|
|
|
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-19 20:42:49 +01:00
|
|
|
// If not already paid, check if we could pay for it.
|
|
|
|
const res = await getCoinsForPayment(ws, contractData);
|
2019-12-02 00:42:40 +01:00
|
|
|
|
|
|
|
if (!res) {
|
|
|
|
console.log("not confirming payment, insufficient coins");
|
|
|
|
return {
|
|
|
|
status: "insufficient-balance",
|
2019-12-19 20:42:49 +01:00
|
|
|
contractTermsRaw: d.contractTermsRaw,
|
2019-12-02 00:42:40 +01:00
|
|
|
proposalId: proposal.proposalId,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2020-05-12 12:14:48 +02:00
|
|
|
const costInfo = await getTotalPaymentCost(ws, res);
|
2020-05-15 19:53:49 +02:00
|
|
|
console.log("costInfo", costInfo);
|
|
|
|
console.log("coinsForPayment", res);
|
2020-05-12 12:14:48 +02:00
|
|
|
const totalFees = Amounts.sub(costInfo.totalCost, res.paymentAmount).amount;
|
2019-12-25 19:11:20 +01:00
|
|
|
|
2019-12-02 00:42:40 +01:00
|
|
|
return {
|
|
|
|
status: "payment-possible",
|
2019-12-19 20:42:49 +01:00
|
|
|
contractTermsRaw: d.contractTermsRaw,
|
2019-12-02 00:42:40 +01:00
|
|
|
proposalId: proposal.proposalId,
|
2019-12-25 19:11:20 +01:00
|
|
|
totalFees,
|
2019-12-02 00:42:40 +01:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2019-12-16 22:42:10 +01:00
|
|
|
if (uriResult.sessionId && purchase.lastSessionId !== uriResult.sessionId) {
|
2019-12-19 20:42:49 +01:00
|
|
|
console.log(
|
|
|
|
"automatically re-submitting payment with different session ID",
|
|
|
|
);
|
2020-03-30 12:39:32 +02:00
|
|
|
await ws.db.runWithWriteTransaction([Stores.purchases], async (tx) => {
|
2019-12-16 22:42:10 +01:00
|
|
|
const p = await tx.get(Stores.purchases, proposalId);
|
|
|
|
if (!p) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
p.lastSessionId = uriResult.sessionId;
|
|
|
|
await tx.put(Stores.purchases, p);
|
|
|
|
});
|
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-19 20:42:49 +01:00
|
|
|
contractTermsRaw: purchase.contractTermsRaw,
|
|
|
|
nextUrl: getNextUrl(purchase.contractData),
|
2019-12-02 00:42:40 +01:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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-19 20:42:49 +01:00
|
|
|
let purchase = await ws.db.get(
|
|
|
|
Stores.purchases,
|
|
|
|
d.contractData.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}`);
|
2020-03-30 12:39:32 +02: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-19 20:42:49 +01:00
|
|
|
const res = await getCoinsForPayment(ws, d.contractData);
|
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");
|
|
|
|
}
|
|
|
|
|
2019-12-25 19:11:20 +01:00
|
|
|
const depositPermissions: CoinDepositPermission[] = [];
|
|
|
|
for (let i = 0; i < res.coinPubs.length; i++) {
|
|
|
|
const coin = await ws.db.get(Stores.coins, res.coinPubs[i]);
|
|
|
|
if (!coin) {
|
|
|
|
throw Error("can't pay, allocated coin not found anymore");
|
|
|
|
}
|
|
|
|
const denom = await ws.db.get(Stores.denominations, [
|
|
|
|
coin.exchangeBaseUrl,
|
|
|
|
coin.denomPub,
|
|
|
|
]);
|
|
|
|
if (!denom) {
|
|
|
|
throw Error(
|
|
|
|
"can't pay, denomination of allocated coin not found anymore",
|
|
|
|
);
|
|
|
|
}
|
|
|
|
const dp = await ws.cryptoApi.signDepositPermission({
|
|
|
|
coinPriv: coin.coinPriv,
|
|
|
|
coinPub: coin.coinPub,
|
|
|
|
contractTermsHash: d.contractData.contractTermsHash,
|
2020-07-21 08:53:48 +02:00
|
|
|
denomPubHash: coin.denomPubHash,
|
2019-12-25 19:11:20 +01:00
|
|
|
denomSig: coin.denomSig,
|
|
|
|
exchangeBaseUrl: coin.exchangeBaseUrl,
|
|
|
|
feeDeposit: denom.feeDeposit,
|
|
|
|
merchantPub: d.contractData.merchantPub,
|
|
|
|
refundDeadline: d.contractData.refundDeadline,
|
|
|
|
spendAmount: res.coinContributions[i],
|
|
|
|
timestamp: d.contractData.timestamp,
|
|
|
|
wireInfoHash: d.contractData.wireInfoHash,
|
|
|
|
});
|
|
|
|
depositPermissions.push(dp);
|
|
|
|
}
|
2019-12-15 21:40:06 +01:00
|
|
|
purchase = await recordConfirmPay(
|
|
|
|
ws,
|
|
|
|
proposal,
|
2019-12-25 19:11:20 +01:00
|
|
|
res,
|
|
|
|
depositPermissions,
|
2019-12-19 20:42:49 +01:00
|
|
|
sessionIdOverride,
|
2019-12-15 21:40:06 +01:00
|
|
|
);
|
2019-12-02 00:42:40 +01:00
|
|
|
|
2019-12-10 12:22:29 +01:00
|
|
|
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,
|
2020-04-06 17:45:41 +02:00
|
|
|
forceNow = false,
|
2019-12-05 19:38:19 +01:00
|
|
|
): Promise<void> {
|
2020-07-22 10:52:03 +02:00
|
|
|
const onOpErr = (e: OperationErrorDetails): Promise<void> =>
|
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,
|
2020-04-06 20:02:01 +02:00
|
|
|
): Promise<void> {
|
2020-03-30 12:39:32 +02: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
|
|
|
}
|
2019-12-20 01:25:22 +01:00
|
|
|
|
2019-12-25 19:11:20 +01:00
|
|
|
export async function refuseProposal(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
proposalId: string,
|
2020-04-06 20:02:01 +02:00
|
|
|
): Promise<void> {
|
2019-12-25 19:11:20 +01:00
|
|
|
const success = await ws.db.runWithWriteTransaction(
|
|
|
|
[Stores.proposals],
|
2020-03-30 12:39:32 +02:00
|
|
|
async (tx) => {
|
2019-12-25 19:11:20 +01:00
|
|
|
const proposal = await tx.get(Stores.proposals, proposalId);
|
|
|
|
if (!proposal) {
|
|
|
|
logger.trace(`proposal ${proposalId} not found, won't refuse proposal`);
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
if (proposal.proposalStatus !== ProposalStatus.PROPOSED) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
proposal.proposalStatus = ProposalStatus.REFUSED;
|
|
|
|
await tx.put(Stores.proposals, proposal);
|
|
|
|
return true;
|
|
|
|
},
|
|
|
|
);
|
2019-12-20 01:25:22 +01:00
|
|
|
if (success) {
|
|
|
|
ws.notify({
|
2020-07-20 12:50:32 +02:00
|
|
|
type: NotificationType.ProposalRefused,
|
2019-12-20 01:25:22 +01:00
|
|
|
});
|
|
|
|
}
|
2019-12-25 19:11:20 +01:00
|
|
|
}
|