wallet-core/packages/taler-wallet-core/src/operations/pay.ts

1289 lines
37 KiB
TypeScript
Raw Normal View History

/*
This file is part of GNU Taler
2019-12-25 21:47:57 +01:00
(C) 2019 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
* 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,
ProposalRecord,
ProposalStatus,
PurchaseRecord,
Stores,
WalletContractData,
2020-09-03 17:08:26 +02:00
CoinRecord,
DenominationRecord,
2020-09-08 17:15:33 +02:00
PayCoinSelection,
AbortStatus,
2019-12-15 19:08:07 +01:00
} from "../types/dbTypes";
import { NotificationType } from "../types/notifications";
import {
codecForProposal,
codecForContractTerms,
2019-12-25 19:11:20 +01:00
CoinDepositPermission,
2020-07-21 08:53:48 +02:00
codecForMerchantPayResponse,
} from "../types/talerTypes";
import {
ConfirmPayResult,
2020-09-01 14:57:22 +02:00
TalerErrorDetails,
2019-12-15 19:08:07 +01:00
PreparePayResult,
RefreshReason,
PreparePayResultType,
2020-08-11 14:02:11 +02:00
ConfirmPayResultType,
} from "../types/walletTypes";
import * as Amounts from "../util/amounts";
2019-12-15 19:08:07 +01:00
import { AmountJson } from "../util/amounts";
import { Logger } from "../util/logging";
import { parsePayUri } from "../util/taleruri";
import {
guardOperationException,
OperationFailedAndReportedError,
OperationFailedError,
} from "./errors";
2019-12-15 19:08:07 +01:00
import { createRefreshGroup, getTotalRefreshCost } from "./refresh";
import { InternalWalletState, EXCHANGE_COINS_LOCK } from "./state";
import {
getTimestampNow,
timestampAddDuration,
Duration,
durationMax,
durationMin,
2020-09-03 17:08:26 +02:00
isTimestampExpired,
2020-09-07 12:24:22 +02:00
durationMul,
durationAdd,
} from "../util/time";
2019-12-25 19:11:20 +01:00
import { strcmp, canonicalJson } from "../util/helpers";
2020-08-19 17:25:38 +02:00
import {
readSuccessResponseJsonOrThrow,
throwUnexpectedRequestError,
getHttpResponseErrorDetails,
readSuccessResponseJsonOrErrorCode,
2020-08-19 17:25:38 +02:00
} from "../util/http";
import { TalerErrorCode } from "../TalerErrorCode";
import { URL } from "../util/url";
import {
initRetryInfo,
updateRetryInfoTimeout,
getRetryDuration,
} from "../util/retries";
2019-12-25 21:47:57 +01:00
/**
* Logger.
*/
const logger = new Logger("pay.ts");
/**
* 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-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-09-08 17:15:33 +02:00
): Promise<AmountJson> {
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,
2020-09-08 17:33:10 +02:00
coin.denomPubHash,
2019-12-25 19:11:20 +01:00
]);
if (!denom) {
throw Error(
"can't calculate payment cost, denomination for coin not found",
);
}
const allDenoms = await ws.db
.iterIndex(
Stores.denominations.exchangeBaseUrlIndex,
coin.exchangeBaseUrl,
)
.toArray();
const amountLeft = Amounts.sub(denom.value, pcs.coinContributions[i])
.amount;
const refreshCost = getTotalRefreshCost(allDenoms, denom, amountLeft);
costs.push(pcs.coinContributions[i]);
2019-12-25 19:11:20 +01:00
costs.push(refreshCost);
}
2020-09-08 17:15:33 +02:00
return Amounts.sum(costs).amount;
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.
*/
export function selectPayCoins(
2019-12-25 19:11:20 +01:00
acis: AvailableCoinInfo[],
contractTermsAmount: AmountJson,
customerWireFees: AmountJson,
depositFeeLimit: AmountJson,
2019-12-25 19:11:20 +01:00
): PayCoinSelection | undefined {
if (acis.length === 0) {
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
// (to guarantee deterministic results)
2019-12-25 19:11:20 +01:00
acis.sort(
(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),
);
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) {
continue;
}
2019-12-25 19:11:20 +01:00
if (amountPayRemaining.value === 0 && amountPayRemaining.fraction === 0) {
// We have spent enough!
break;
}
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-25 19:11:20 +01:00
let coinSpend: AmountJson;
const amountActualAvailable = Amounts.sub(
aci.availableAmount,
depositFeeSpend,
).amount;
2019-12-25 19:11:20 +01:00
if (Amounts.cmp(amountActualAvailable, amountPayRemaining) > 0) {
// 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;
// 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 {
// 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)
.amount;
}
2019-12-25 19:11:20 +01:00
coinPubs.push(aci.coinPub);
coinContributions.push(coinSpend);
}
if (Amounts.isZero(amountPayRemaining)) {
return {
paymentAmount: contractTermsAmount,
2019-12-25 19:11:20 +01:00
coinContributions,
coinPubs,
customerDepositFees,
customerWireFees,
};
}
return undefined;
}
2020-09-04 08:34:11 +02:00
export function isSpendableCoin(
coin: CoinRecord,
denom: DenominationRecord,
): boolean {
2020-09-03 17:08:26 +02:00
if (coin.suspended) {
return false;
}
if (coin.status !== CoinStatus.Fresh) {
return false;
}
if (isTimestampExpired(denom.stampExpireDeposit)) {
return false;
}
return true;
}
/**
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.
*/
async function getCoinsForPayment(
ws: InternalWalletState,
2019-12-25 19:11:20 +01:00
contractData: WalletContractData,
): Promise<PayCoinSelection | undefined> {
const remainingAmount = contractData.amount;
2019-12-12 22:39:45 +01:00
const exchanges = await ws.db.iter(Stores.exchanges).toArray();
for (const exchange of exchanges) {
2020-04-06 17:45:41 +02:00
let isOkay = false;
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) {
if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) {
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) {
for (const auditor of exchangeDetails.auditors) {
if (auditor.auditor_pub === allowedAuditor.auditorPub) {
isOkay = true;
break;
}
}
if (isOkay) {
break;
}
}
}
if (!isOkay) {
continue;
}
const coins = await ws.db
.iterIndex(Stores.coins.exchangeBaseUrlIndex, exchange.baseUrl)
.toArray();
if (!coins || coins.length === 0) {
continue;
}
// Denomination of the first coin, we assume that all other
// coins have the same currency
2019-12-12 22:39:45 +01:00
const firstDenom = await ws.db.get(Stores.denominations, [
exchange.baseUrl,
2020-09-08 17:33:10 +02:00
coins[0].denomPubHash,
]);
if (!firstDenom) {
throw Error("db inconsistent");
}
const currency = firstDenom.value.currency;
2019-12-25 19:11:20 +01:00
const acis: AvailableCoinInfo[] = [];
for (const coin of coins) {
2019-12-12 22:39:45 +01:00
const denom = await ws.db.get(Stores.denominations, [
exchange.baseUrl,
2020-09-08 17:33:10 +02:00
coin.denomPubHash,
]);
if (!denom) {
throw Error("db inconsistent");
}
if (denom.value.currency !== currency) {
logger.warn(
`same pubkey for different currencies at exchange ${exchange.baseUrl}`,
);
continue;
}
2020-09-03 17:08:26 +02:00
if (!isSpendableCoin(coin, denom)) {
continue;
}
2019-12-25 19:11:20 +01:00
acis.push({
availableAmount: coin.currentAmount,
coinPub: coin.coinPub,
denomPub: coin.denomPub,
feeDeposit: denom.feeDeposit,
});
}
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
) {
wireFee = fee.wireFee;
break;
}
}
let customerWireFee: AmountJson;
if (wireFee) {
2019-12-25 19:11:20 +01:00
const amortizedWireFee = Amounts.divide(
wireFee,
contractData.wireFeeAmortization,
);
if (Amounts.cmp(contractData.maxWireFee, amortizedWireFee) < 0) {
customerWireFee = amortizedWireFee;
} else {
customerWireFee = Amounts.getZero(currency);
}
} else {
customerWireFee = Amounts.getZero(currency);
}
2019-12-25 19:11:20 +01:00
// Try if paying using this exchange works
const res = selectPayCoins(
acis,
remainingAmount,
customerWireFee,
2019-12-25 19:11:20 +01:00
contractData.maxDepositFee,
);
if (res) {
2019-12-25 19:11:20 +01:00
return res;
}
}
return undefined;
}
/**
* Record all information that is necessary to
* pay for a proposal in the wallet's database.
*/
async function recordConfirmPay(
ws: InternalWalletState,
proposal: ProposalRecord,
2019-12-25 19:11:20 +01:00
coinSelection: PayCoinSelection,
coinDepositPermissions: CoinDepositPermission[],
sessionIdOverride: string | undefined,
): Promise<PurchaseRecord> {
2019-12-03 00:52:15 +01:00
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}`);
2020-05-12 12:34:28 +02:00
const payCostInfo = await getTotalPaymentCost(ws, coinSelection);
const t: PurchaseRecord = {
abortStatus: AbortStatus.None,
contractTermsRaw: d.contractTermsRaw,
contractData: d.contractData,
lastSessionId: sessionId,
payCoinSelection: coinSelection,
2020-09-08 17:15:33 +02:00
totalPayCost: 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,
lastPayError: undefined,
lastRefundStatusError: undefined,
payRetryInfo: initRetryInfo(),
refundStatusRetryInfo: initRetryInfo(),
refundQueryRequested: false,
2019-12-16 16:20:45 +01:00
timestampFirstSuccessfulPay: undefined,
2019-12-07 18:42:18 +01:00
autoRefundDeadline: undefined,
paymentSubmitPending: true,
2020-07-23 14:05:17 +02:00
refunds: {},
2020-08-19 17:25:38 +02:00
merchantPaySig: undefined,
};
2019-12-12 22:39:45 +01:00
await ws.db.runWithWriteTransaction(
[
Stores.coins,
Stores.purchases,
Stores.proposals,
Stores.refreshGroups,
Stores.denominations,
],
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);
}
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;
const remaining = Amounts.sub(
coin.currentAmount,
2019-12-25 19:11:20 +01:00
coinSelection.coinContributions[i],
);
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);
}
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-05 19:38:19 +01:00
ws.notify({
type: NotificationType.ProposalAccepted,
proposalId: proposal.proposalId,
});
return t;
}
2019-12-05 19:38:19 +01:00
async function incrementProposalRetry(
ws: InternalWalletState,
proposalId: string,
2020-09-01 14:57:22 +02:00
err: TalerErrorDetails | 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);
});
if (err) {
ws.notify({ type: NotificationType.ProposalOperationError, error: err });
}
2019-12-05 19:38:19 +01:00
}
/**
* FIXME: currently pay operations aren't ever automatically retried.
* But we still keep a payRetryInfo around in the database.
*/
async function incrementPurchasePayRetry(
2019-12-05 19:38:19 +01:00
ws: InternalWalletState,
proposalId: string,
2020-09-01 14:57:22 +02:00
err: TalerErrorDetails | undefined,
2019-12-05 19:38:19 +01:00
): Promise<void> {
logger.warn("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;
}
if (!pr.payRetryInfo) {
2019-12-05 19:38:19 +01:00
return;
}
pr.payRetryInfo.retryCounter++;
updateRetryInfoTimeout(pr.payRetryInfo);
pr.lastPayError = err;
await tx.put(Stores.purchases, pr);
});
if (err) {
ws.notify({ type: NotificationType.PayOperationError, error: err });
}
}
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-09-01 14:57:22 +02:00
const onOpErr = (err: TalerErrorDetails): Promise<void> =>
2019-12-05 19:38:19 +01:00
incrementProposalRetry(ws, proposalId, err);
await guardOperationException(
() => processDownloadProposalImpl(ws, proposalId, forceNow),
2019-12-05 19:38:19 +01:00
onOpErr,
);
}
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) => {
if (x.retryInfo.active) {
x.retryInfo = initRetryInfo();
}
return x;
});
}
function getProposalRequestTimeout(proposal: ProposalRecord): Duration {
return durationMax(
{ d_ms: 60000 },
durationMin({ d_ms: 5000 }, getRetryDuration(proposal.retryInfo)),
);
}
2020-09-07 12:24:22 +02:00
function getPayRequestTimeout(purchase: PurchaseRecord): Duration {
return durationMul(
{ d_ms: 5000 },
1 + purchase.payCoinSelection.coinPubs.length / 20,
);
}
2019-12-05 19:38:19 +01:00
async function processDownloadProposalImpl(
ws: InternalWalletState,
proposalId: string,
forceNow: boolean,
2019-12-03 00:52:15 +01:00
): Promise<void> {
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;
}
const orderClaimUrl = new URL(
`orders/${proposal.orderId}/claim`,
proposal.merchantBaseUrl,
).href;
logger.trace("downloading contract from '" + orderClaimUrl + "'");
2020-07-30 13:58:09 +02:00
const requestBody: {
nonce: string;
2020-07-30 13:58:09 +02:00
token?: string;
} = {
nonce: proposal.noncePub,
};
2020-07-30 13:58:09 +02:00
if (proposal.claimToken) {
requestBody.token = proposal.claimToken;
}
const httpResponse = await ws.http.postJson(orderClaimUrl, requestBody, {
timeout: getProposalRequestTimeout(proposal),
});
const r = await readSuccessResponseJsonOrErrorCode(
httpResponse,
codecForProposal(),
);
if (r.isError) {
switch (r.talerErrorResponse.code) {
case TalerErrorCode.ORDERS_ALREADY_CLAIMED:
throw OperationFailedError.fromCode(
TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED,
"order already claimed (likely by other wallet)",
{
orderId: proposal.orderId,
claimUrl: orderClaimUrl,
},
);
default:
throwUnexpectedRequestError(httpResponse, r.talerErrorResponse);
}
}
const proposalResp = r.response;
2019-12-09 13:29:11 +01: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),
);
const parsedContractTerms = codecForContractTerms().decode(
proposalResp.contract_terms,
);
const fulfillmentUrl = parsedContractTerms.fulfillment_url;
2019-12-03 00:52:15 +01:00
const baseUrlForDownload = proposal.merchantBaseUrl;
const baseUrlFromContractTerms = parsedContractTerms.merchant_base_url;
if (baseUrlForDownload !== baseUrlFromContractTerms) {
throw OperationFailedAndReportedError.fromCode(
TalerErrorCode.WALLET_CONTRACT_TERMS_BASE_URL_MISMATCH,
"merchant base URL mismatch",
{
baseUrlForDownload,
baseUrlFromContractTerms,
},
);
}
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;
}
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 = {
contractData: {
amount,
contractTermsHash: contractTermsHash,
2020-08-24 16:30:15 +02:00
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) => ({
auditorBaseUrl: x.url,
auditorPub: x.master_pub,
})),
2020-03-30 12:39:32 +02:00
allowedExchanges: parsedContractTerms.exchanges.map((x) => ({
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,
maxDepositFee: Amounts.parseOrThrow(parsedContractTerms.max_fee),
merchant: parsedContractTerms.merchant,
products: parsedContractTerms.products,
summaryI18n: parsedContractTerms.summary_i18n,
},
contractTermsRaw: JSON.stringify(proposalResp.contract_terms),
2019-12-16 22:42:10 +01:00
};
2019-12-03 00:52:15 +01:00
if (
fulfillmentUrl &&
(fulfillmentUrl.startsWith("http://") ||
fulfillmentUrl.startsWith("https://"))
2019-12-03 00:52:15 +01:00
) {
const differentPurchase = await tx.getIndexed(
Stores.purchases.fulfillmentUrlIndex,
fulfillmentUrl,
);
if (differentPurchase) {
logger.warn("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
}
/**
* 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(
ws: InternalWalletState,
2019-12-06 12:47:28 +01:00
merchantBaseUrl: string,
orderId: string,
sessionId: string | undefined,
2020-07-30 13:58:09 +02:00
claimToken: string | undefined,
): 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],
);
if (oldProposal) {
2019-12-03 00:52:15 +01:00
await processDownloadProposal(ws, oldProposal.proposalId);
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,
noncePriv: priv,
2019-12-03 00:52:15 +01:00
noncePub: pub,
2020-07-30 13:58:09 +02:00
claimToken,
timestamp: getTimestampNow(),
2019-12-06 12:47:28 +01:00
merchantBaseUrl,
orderId,
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,
downloadSessionId: sessionId,
};
2020-03-30 12:39:32 +02:00
await ws.db.runWithWriteTransaction([Stores.proposals], async (tx) => {
const existingRecord = await tx.getIndexed(
Stores.proposals.urlAndOrderIdIndex,
[merchantBaseUrl, orderId],
);
if (existingRecord) {
// Created concurrently
return;
}
await tx.put(Stores.proposals, proposalRecord);
});
2019-12-03 00:52:15 +01:00
await processDownloadProposal(ws, proposalId);
return proposalId;
}
2020-08-19 17:25:38 +02:00
async function storeFirstPaySuccess(
ws: InternalWalletState,
proposalId: string,
sessionId: string | undefined,
paySig: string,
): Promise<void> {
const now = getTimestampNow();
await ws.db.runWithWriteTransaction([Stores.purchases], async (tx) => {
const purchase = await tx.get(Stores.purchases, proposalId);
2020-08-19 17:25:38 +02:00
if (!purchase) {
logger.warn("purchase does not exist anymore");
return;
}
const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
if (!isFirst) {
logger.warn("payment success already stored");
return;
}
purchase.timestampFirstSuccessfulPay = now;
purchase.paymentSubmitPending = false;
purchase.lastPayError = undefined;
purchase.lastSessionId = sessionId;
purchase.payRetryInfo = initRetryInfo(false);
purchase.merchantPaySig = paySig;
if (isFirst) {
const ar = purchase.contractData.autoRefund;
if (ar) {
logger.info("auto_refund present");
purchase.refundQueryRequested = true;
purchase.refundStatusRetryInfo = initRetryInfo();
purchase.lastRefundStatusError = undefined;
purchase.autoRefundDeadline = timestampAddDuration(now, ar);
2020-08-19 17:25:38 +02:00
}
}
2020-08-19 17:25:38 +02:00
await tx.put(Stores.purchases, purchase);
});
2020-08-19 17:25:38 +02:00
}
async function storePayReplaySuccess(
ws: InternalWalletState,
proposalId: string,
sessionId: string | undefined,
): Promise<void> {
await ws.db.runWithWriteTransaction([Stores.purchases], async (tx) => {
const purchase = await tx.get(Stores.purchases, proposalId);
2020-08-19 17:25:38 +02:00
if (!purchase) {
logger.warn("purchase does not exist anymore");
return;
}
const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
if (isFirst) {
throw Error("invalid payment state");
}
purchase.paymentSubmitPending = false;
purchase.lastPayError = undefined;
purchase.payRetryInfo = initRetryInfo(false);
purchase.lastSessionId = sessionId;
await tx.put(Stores.purchases, purchase);
});
2020-08-19 17:25:38 +02:00
}
/**
* Submit a payment to the merchant.
*
* If the wallet has previously paid, it just transmits the merchant's
* own signature certifying that the wallet has previously paid.
*/
async function submitPay(
ws: InternalWalletState,
2019-12-03 00:52:15 +01:00
proposalId: string,
): Promise<ConfirmPayResult> {
2019-12-12 22:39:45 +01:00
const purchase = await ws.db.get(Stores.purchases, proposalId);
if (!purchase) {
2019-12-03 00:52:15 +01:00
throw Error("Purchase not found: " + proposalId);
}
if (purchase.abortStatus !== AbortStatus.None) {
throw Error("not submitting payment for aborted purchase");
}
const sessionId = purchase.lastSessionId;
logger.trace("paying with session ID", sessionId);
2020-08-19 17:25:38 +02:00
if (!purchase.merchantPaySig) {
const payUrl = new URL(
`orders/${purchase.contractData.orderId}/pay`,
purchase.contractData.merchantBaseUrl,
).href;
2020-07-21 08:53:48 +02:00
2020-08-19 17:25:38 +02:00
const reqBody = {
coins: purchase.coinDepositPermissions,
session_id: purchase.lastSessionId,
};
2020-08-19 17:25:38 +02:00
logger.trace("making pay request", JSON.stringify(reqBody, undefined, 2));
2020-08-19 17:25:38 +02:00
const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
ws.http.postJson(payUrl, reqBody, {
2020-09-07 12:24:22 +02:00
timeout: getPayRequestTimeout(purchase),
}),
2020-08-19 17:25:38 +02:00
);
2020-08-19 17:25:38 +02:00
const merchantResp = await readSuccessResponseJsonOrThrow(
resp,
codecForMerchantPayResponse(),
);
2020-08-19 17:25:38 +02:00
logger.trace("got success from pay URL", merchantResp);
2020-08-19 17:25:38 +02:00
const merchantPub = purchase.contractData.merchantPub;
const valid: boolean = await ws.cryptoApi.isValidPaymentSignature(
merchantResp.sig,
purchase.contractData.contractTermsHash,
merchantPub,
);
2019-12-15 21:40:06 +01:00
2020-08-19 17:25:38 +02:00
if (!valid) {
logger.error("merchant payment signature invalid");
// FIXME: properly display error
throw Error("merchant payment signature invalid");
2019-12-07 18:42:18 +01:00
}
2020-08-19 17:25:38 +02:00
await storeFirstPaySuccess(ws, proposalId, sessionId, merchantResp.sig);
} else {
const payAgainUrl = new URL(
`orders/${purchase.contractData.orderId}/paid`,
purchase.contractData.merchantBaseUrl,
).href;
const reqBody = {
sig: purchase.merchantPaySig,
h_contract: purchase.contractData.contractTermsHash,
session_id: sessionId ?? "",
};
const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
ws.http.postJson(payAgainUrl, reqBody),
);
if (resp.status !== 204) {
throw OperationFailedError.fromCode(
TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
"/paid failed",
getHttpResponseErrorDetails(resp),
);
}
await storePayReplaySuccess(ws, proposalId, sessionId);
}
2020-08-11 14:02:11 +02:00
return {
type: ConfirmPayResultType.Done,
contractTerms: JSON.parse(purchase.contractTermsRaw),
2020-08-11 14:02:11 +02:00
};
}
/**
* 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(
ws: InternalWalletState,
talerPayUri: string,
): Promise<PreparePayResult> {
const uriResult = parsePayUri(talerPayUri);
if (!uriResult) {
throw OperationFailedError.fromCode(
TalerErrorCode.WALLET_INVALID_TALER_PAY_URI,
`invalid taler://pay URI (${talerPayUri})`,
{
talerPayUri,
2020-07-30 13:58:09 +02:00
},
);
}
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,
2020-07-30 13:58:09 +02:00
uriResult.claimToken,
);
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-03 00:52:15 +01:00
if (proposal.proposalStatus === ProposalStatus.REPURCHASE) {
const existingProposalId = proposal.repurchaseProposalId;
if (!existingProposalId) {
throw Error("invalid proposal state");
}
logger.trace("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) {
logger.error("bad proposal", proposal);
2019-12-03 00:52:15 +01:00
throw Error("proposal is in invalid state");
}
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-06 00:56:31 +01:00
proposalId = proposal.proposalId;
2019-12-03 00:52:15 +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);
if (!purchase) {
// If not already paid, check if we could pay for it.
const res = await getCoinsForPayment(ws, contractData);
if (!res) {
2020-07-30 13:58:09 +02:00
logger.info("not confirming payment, insufficient coins");
return {
status: PreparePayResultType.InsufficientBalance,
contractTerms: JSON.parse(d.contractTermsRaw),
proposalId: proposal.proposalId,
2020-08-10 16:35:41 +02:00
amountRaw: Amounts.stringify(d.contractData.amount),
};
}
2020-09-08 17:15:33 +02:00
const totalCost = await getTotalPaymentCost(ws, res);
logger.trace("costInfo", totalCost);
2020-07-29 19:40:41 +02:00
logger.trace("coinsForPayment", res);
2019-12-25 19:11:20 +01:00
return {
status: PreparePayResultType.PaymentPossible,
contractTerms: JSON.parse(d.contractTermsRaw),
proposalId: proposal.proposalId,
2020-09-08 17:15:33 +02:00
amountEffective: Amounts.stringify(totalCost),
2020-07-29 19:40:41 +02:00
amountRaw: Amounts.stringify(res.paymentAmount),
};
}
if (purchase.lastSessionId !== uriResult.sessionId) {
2020-07-29 19:40:41 +02:00
logger.trace(
"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);
});
const r = await guardOperationException(
() => submitPay(ws, proposalId),
(e: TalerErrorDetails): Promise<void> =>
incrementPurchasePayRetry(ws, proposalId, e),
);
2020-08-11 14:02:11 +02:00
if (r.type !== ConfirmPayResultType.Done) {
throw Error("submitting pay failed");
}
return {
status: PreparePayResultType.AlreadyConfirmed,
contractTerms: JSON.parse(purchase.contractTermsRaw),
contractTermsHash: purchase.contractData.contractTermsHash,
paid: true,
2020-08-10 16:35:41 +02:00
amountRaw: Amounts.stringify(purchase.contractData.amount),
2020-09-08 17:15:33 +02:00
amountEffective: Amounts.stringify(purchase.totalPayCost),
proposalId,
};
} else if (!purchase.timestampFirstSuccessfulPay) {
return {
status: PreparePayResultType.AlreadyConfirmed,
contractTerms: JSON.parse(purchase.contractTermsRaw),
contractTermsHash: purchase.contractData.contractTermsHash,
paid: false,
2020-08-10 16:35:41 +02:00
amountRaw: Amounts.stringify(purchase.contractData.amount),
2020-09-08 17:15:33 +02:00
amountEffective: Amounts.stringify(purchase.totalPayCost),
proposalId,
2020-07-30 13:58:09 +02:00
};
} else {
const paid = !purchase.paymentSubmitPending;
return {
status: PreparePayResultType.AlreadyConfirmed,
contractTerms: JSON.parse(purchase.contractTermsRaw),
contractTermsHash: purchase.contractData.contractTermsHash,
paid,
2020-08-10 16:35:41 +02:00
amountRaw: Amounts.stringify(purchase.contractData.amount),
2020-09-08 17:15:33 +02:00
amountEffective: Amounts.stringify(purchase.totalPayCost),
...(paid ? { nextUrl: purchase.contractData.orderId } : {}),
proposalId,
};
}
}
/**
* 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);
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");
}
let purchase = await ws.db.get(
Stores.purchases,
d.contractData.contractTermsHash,
);
if (purchase) {
if (
sessionIdOverride !== undefined &&
sessionIdOverride != purchase.lastSessionId
) {
logger.trace(`changing session ID to ${sessionIdOverride}`);
2020-03-30 12:39:32 +02:00
await ws.db.mutate(Stores.purchases, purchase.proposalId, (x) => {
x.lastSessionId = sessionIdOverride;
x.paymentSubmitPending = true;
return x;
});
}
logger.trace("confirmPay: submitting payment for existing purchase");
return await guardOperationException(
() => submitPay(ws, proposalId),
(e: TalerErrorDetails): Promise<void> =>
incrementPurchasePayRetry(ws, proposalId, e),
);
}
logger.trace("confirmPay: purchase record does not exist yet");
const res = await getCoinsForPayment(ws, d.contractData);
logger.trace("coin selection result", res);
if (!res) {
// Should not happen, since checkPay should be called first
2020-07-30 13:58:09 +02:00
logger.warn("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,
2020-09-08 17:33:10 +02:00
coin.denomPubHash,
2019-12-25 19:11:20 +01:00
]);
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,
sessionIdOverride,
2019-12-15 21:40:06 +01:00
);
return await guardOperationException(
() => submitPay(ws, proposalId),
(e: TalerErrorDetails): Promise<void> =>
incrementPurchasePayRetry(ws, proposalId, e),
);
}
2019-12-03 14:40:05 +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-09-01 14:57:22 +02:00
const onOpErr = (e: TalerErrorDetails): Promise<void> =>
incrementPurchasePayRetry(ws, proposalId, e);
2019-12-05 19:38:19 +01:00
await guardOperationException(
() => processPurchasePayImpl(ws, proposalId, forceNow),
2019-12-05 19:38:19 +01:00
onOpErr,
);
}
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) => {
if (x.payRetryInfo.active) {
x.payRetryInfo = initRetryInfo();
}
return x;
});
}
async function processPurchasePayImpl(
2019-12-05 19:38:19 +01:00
ws: InternalWalletState,
proposalId: string,
forceNow: boolean,
2019-12-05 19:38:19 +01:00
): Promise<void> {
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;
}
if (!purchase.paymentSubmitPending) {
return;
}
logger.trace(`processing purchase pay ${proposalId}`);
await submitPay(ws, proposalId);
}
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
}