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

1776 lines
51 KiB
TypeScript
Raw Normal View History

/*
This file is part of GNU Taler
(C) 2019-2022 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.
*/
2021-03-27 19:35:44 +01:00
import {
AbsoluteTime,
AgeRestriction,
2021-03-27 19:35:44 +01:00
AmountJson,
Amounts,
codecForContractTerms,
codecForMerchantPayResponse,
codecForProposal,
CoinDepositPermission,
ConfirmPayResult,
ConfirmPayResultType,
ContractTerms,
Duration,
2021-03-27 19:35:44 +01:00
durationMax,
durationMin,
durationMul,
encodeCrock,
getRandomBytes,
HttpStatusCode,
j2s,
Logger,
NotificationType,
parsePayUri,
PreparePayResult,
PreparePayResultType,
RefreshReason,
TalerErrorCode,
TalerErrorDetail,
2022-03-18 15:32:41 +01:00
TalerProtocolTimestamp,
TransactionType,
URL,
2021-03-27 19:35:44 +01:00
} from "@gnu-taler/taler-util";
2022-03-23 21:24:23 +01:00
import {
EXCHANGE_COINS_LOCK,
InternalWalletState,
} from "../internal-wallet-state.js";
2021-06-14 16:08:58 +02:00
import {
AbortStatus,
AllowedAuditorInfo,
AllowedExchangeInfo,
BackupProviderStateTag,
2021-06-14 16:08:58 +02:00
CoinRecord,
CoinStatus,
DenominationRecord,
ProposalRecord,
ProposalStatus,
PurchaseRecord,
WalletContractData,
WalletStoresV1,
2021-06-14 16:08:58 +02:00
} from "../db.js";
import {
makeErrorDetail,
makePendingOperationFailedError,
TalerError,
} from "../errors.js";
import {
AvailableCoinInfo,
CoinCandidateSelection,
PayCoinSelection,
PreviousPayCoins,
selectPayCoins,
} from "../util/coinSelection.js";
import { ContractTermsUtil } from "../util/contractTerms.js";
2021-06-14 16:08:58 +02:00
import {
getHttpResponseErrorDetails,
readSuccessResponseJsonOrErrorCode,
readSuccessResponseJsonOrThrow,
readTalerErrorResponse,
2021-08-24 15:08:34 +02:00
readUnexpectedResponseDetails,
throwUnexpectedRequestError,
2021-06-14 16:08:58 +02:00
} from "../util/http.js";
import { GetReadWriteAccess } from "../util/query.js";
2021-06-14 16:08:58 +02:00
import {
getRetryDuration,
resetRetryInfo,
RetryInfo,
updateRetryInfoTimeout,
} from "../util/retries.js";
import { getExchangeDetails } from "./exchanges.js";
import { createRefreshGroup, getTotalRefreshCost } from "./refresh.js";
import { guardOperationException } from "./common.js";
2022-03-23 21:24:23 +01:00
import { EddsaKeypair } from "../crypto/cryptoImplementation.js";
2019-12-25 21:47:57 +01:00
/**
* Logger.
*/
const logger = new Logger("pay.ts");
/**
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> {
2021-06-09 15:14:17 +02:00
return ws.db
.mktx((x) => ({ coins: x.coins, denominations: x.denominations }))
.runReadOnly(async (tx) => {
const costs: AmountJson[] = [];
2021-06-09 15:14:17 +02:00
for (let i = 0; i < pcs.coinPubs.length; i++) {
const coin = await tx.coins.get(pcs.coinPubs[i]);
if (!coin) {
throw Error("can't calculate payment cost, coin not found");
}
const denom = await tx.denominations.get([
coin.exchangeBaseUrl,
coin.denomPubHash,
]);
if (!denom) {
throw Error(
"can't calculate payment cost, denomination for coin not found",
);
}
const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
.iter(coin.exchangeBaseUrl)
.filter((x) =>
Amounts.isSameCurrency(x.value, pcs.coinContributions[i]),
);
2022-01-13 22:01:14 +01:00
const amountLeft = Amounts.sub(
denom.value,
pcs.coinContributions[i],
).amount;
2021-06-09 15:14:17 +02:00
const refreshCost = getTotalRefreshCost(allDenoms, denom, amountLeft);
costs.push(pcs.coinContributions[i]);
costs.push(refreshCost);
}
2021-07-12 15:55:31 +02:00
const zero = Amounts.getZero(pcs.paymentAmount.currency);
return Amounts.sum([zero, ...costs]).amount;
2021-06-09 15:14:17 +02:00
});
2019-12-25 19:11:20 +01:00
}
function isSpendableCoin(coin: CoinRecord, denom: DenominationRecord): boolean {
2020-09-03 17:08:26 +02:00
if (coin.suspended) {
return false;
}
if (denom.isRevoked) {
return false;
}
if (!denom.isOffered) {
return false;
}
2020-09-03 17:08:26 +02:00
if (coin.status !== CoinStatus.Fresh) {
return false;
}
2022-03-18 15:32:41 +01:00
if (
AbsoluteTime.isExpired(AbsoluteTime.fromTimestamp(denom.stampExpireDeposit))
) {
2020-09-03 17:08:26 +02:00
return false;
}
return true;
}
2021-01-18 23:35:41 +01:00
export interface CoinSelectionRequest {
amount: AmountJson;
2021-01-18 23:35:41 +01:00
allowedAuditors: AllowedAuditorInfo[];
allowedExchanges: AllowedExchangeInfo[];
/**
* Timestamp of the contract.
*/
2022-03-18 15:32:41 +01:00
timestamp: TalerProtocolTimestamp;
2021-01-18 23:35:41 +01:00
wireMethod: string;
wireFeeAmortization: number;
maxWireFee: AmountJson;
maxDepositFee: AmountJson;
/**
* Minimum age requirement for the coin selection.
*
* When present, only select coins with either no age restriction
* or coins with an age commitment that matches the minimum age.
*/
minimumAge?: number;
2021-01-18 23:35:41 +01:00
}
/**
* Get candidate coins. From these candidate coins,
* the actual contributions will be computed later.
2019-12-25 19:11:20 +01:00
*
* The resulting candidate coin list is sorted deterministically.
*
* TODO: Exclude more coins:
* - when we already have a coin with more remaining amount than
* the payment amount, coins with even higher amounts can be skipped.
*/
export async function getCandidatePayCoins(
ws: InternalWalletState,
2021-01-18 23:35:41 +01:00
req: CoinSelectionRequest,
): Promise<CoinCandidateSelection> {
const candidateCoins: AvailableCoinInfo[] = [];
const wireFeesPerExchange: Record<string, AmountJson> = {};
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => ({
exchanges: x.exchanges,
exchangeDetails: x.exchangeDetails,
denominations: x.denominations,
coins: x.coins,
}))
.runReadOnly(async (tx) => {
const exchanges = await tx.exchanges.iter().toArray();
for (const exchange of exchanges) {
let isOkay = false;
const exchangeDetails = await getExchangeDetails(tx, exchange.baseUrl);
if (!exchangeDetails) {
continue;
}
const exchangeFees = exchangeDetails.wireInfo;
if (!exchangeFees) {
continue;
}
2021-06-09 15:14:17 +02:00
// is the exchange explicitly allowed?
for (const allowedExchange of req.allowedExchanges) {
if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) {
isOkay = true;
break;
}
}
2021-06-09 15:14:17 +02:00
// is the exchange allowed because of one of its auditors?
if (!isOkay) {
for (const allowedAuditor of req.allowedAuditors) {
for (const auditor of exchangeDetails.auditors) {
if (auditor.auditor_pub === allowedAuditor.auditorPub) {
isOkay = true;
break;
}
}
if (isOkay) {
break;
}
}
}
2021-06-09 15:14:17 +02:00
if (!isOkay) {
continue;
}
2021-06-09 15:14:17 +02:00
const coins = await tx.coins.indexes.byBaseUrl
.iter(exchange.baseUrl)
.toArray();
2021-06-09 15:14:17 +02:00
if (!coins || coins.length === 0) {
continue;
}
2021-06-09 15:14:17 +02:00
// Denomination of the first coin, we assume that all other
// coins have the same currency
2022-01-13 22:01:14 +01:00
const firstDenom = await ws.getDenomInfo(
ws,
tx,
2021-06-09 15:14:17 +02:00
exchange.baseUrl,
coins[0].denomPubHash,
2022-01-13 22:01:14 +01:00
);
2021-06-09 15:14:17 +02:00
if (!firstDenom) {
throw Error("db inconsistent");
}
const currency = firstDenom.value.currency;
for (const coin of coins) {
const denom = await tx.denominations.get([
exchange.baseUrl,
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;
}
if (!isSpendableCoin(coin, denom)) {
continue;
}
candidateCoins.push({
availableAmount: coin.currentAmount,
coinPub: coin.coinPub,
denomPub: denom.denomPub,
2021-06-09 15:14:17 +02:00
feeDeposit: denom.feeDeposit,
exchangeBaseUrl: denom.exchangeBaseUrl,
ageCommitmentProof: coin.ageCommitmentProof,
2021-06-09 15:14:17 +02:00
});
}
2021-06-09 15:14:17 +02:00
let wireFee: AmountJson | undefined;
for (const fee of exchangeFees.feesForType[req.wireMethod] || []) {
if (
fee.startStamp <= req.timestamp &&
fee.endStamp >= req.timestamp
) {
wireFee = fee.wireFee;
break;
}
}
if (wireFee) {
wireFeesPerExchange[exchange.baseUrl] = wireFee;
}
}
2021-06-09 15:14:17 +02:00
});
return {
candidateCoins,
wireFeesPerExchange,
};
}
2021-06-22 14:55:54 +02:00
/**
* Apply a coin selection to the database. Marks coins as spent
* and creates a refresh session for the remaining amount.
*
* FIXME: This does not deal well with conflicting spends!
* When two payments are made in parallel, the same coin can be selected
* for two payments.
* However, this is a situation that can also happen via sync.
*/
2021-01-18 23:35:41 +01:00
export async function applyCoinSpend(
ws: InternalWalletState,
2021-06-09 15:14:17 +02:00
tx: GetReadWriteAccess<{
coins: typeof WalletStoresV1.coins;
refreshGroups: typeof WalletStoresV1.refreshGroups;
denominations: typeof WalletStoresV1.denominations;
}>,
2021-01-18 23:35:41 +01:00
coinSelection: PayCoinSelection,
allocationId: string,
): Promise<void> {
2022-01-13 22:01:14 +01:00
logger.info(`applying coin spend ${j2s(coinSelection)}`);
2021-01-18 23:35:41 +01:00
for (let i = 0; i < coinSelection.coinPubs.length; i++) {
2021-06-09 15:14:17 +02:00
const coin = await tx.coins.get(coinSelection.coinPubs[i]);
2021-01-18 23:35:41 +01:00
if (!coin) {
throw Error("coin allocated for payment doesn't exist anymore");
}
const contrib = coinSelection.coinContributions[i];
if (coin.status !== CoinStatus.Fresh) {
const alloc = coin.allocation;
if (!alloc) {
continue;
}
if (alloc.id !== allocationId) {
// FIXME: assign error code
throw Error("conflicting coin allocation (id)");
}
if (0 !== Amounts.cmp(alloc.amount, contrib)) {
// FIXME: assign error code
throw Error("conflicting coin allocation (contrib)");
}
continue;
}
2021-01-18 23:35:41 +01:00
coin.status = CoinStatus.Dormant;
coin.allocation = {
id: allocationId,
amount: Amounts.stringify(contrib),
};
const remaining = Amounts.sub(coin.currentAmount, contrib);
2021-01-18 23:35:41 +01:00
if (remaining.saturated) {
throw Error("not enough remaining balance on coin for payment");
}
coin.currentAmount = remaining.amount;
2021-06-09 15:14:17 +02:00
await tx.coins.put(coin);
2021-01-18 23:35:41 +01:00
}
const refreshCoinPubs = coinSelection.coinPubs.map((x) => ({
coinPub: x,
}));
await createRefreshGroup(ws, tx, refreshCoinPubs, RefreshReason.Pay);
}
/**
* 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;
}
2020-12-14 16:45:15 +01:00
logger.trace(
`recording payment on ${proposal.orderId} with session ID ${sessionId}`,
);
2020-05-12 12:34:28 +02:00
const payCostInfo = await getTotalPaymentCost(ws, coinSelection);
const t: PurchaseRecord = {
abortStatus: AbortStatus.None,
2020-12-21 13:23:07 +01:00
download: d,
lastSessionId: sessionId,
payCoinSelection: coinSelection,
payCoinSelectionUid: encodeCrock(getRandomBytes(32)),
2020-09-08 17:15:33 +02:00
totalPayCost: payCostInfo,
2020-07-21 08:53:48 +02:00
coinDepositPermissions,
2022-03-18 15:32:41 +01:00
timestampAccept: AbsoluteTime.toTimestamp(AbsoluteTime.now()),
2019-12-16 16:20:45 +01:00
timestampLastRefundStatus: undefined,
2019-12-03 00:52:15 +01:00
proposalId: proposal.proposalId,
lastPayError: undefined,
lastRefundStatusError: undefined,
payRetryInfo: resetRetryInfo(),
refundStatusRetryInfo: resetRetryInfo(),
refundQueryRequested: false,
2019-12-16 16:20:45 +01:00
timestampFirstSuccessfulPay: undefined,
2019-12-07 18:42:18 +01:00
autoRefundDeadline: undefined,
2022-05-14 23:09:33 +02:00
refundAwaiting: undefined,
paymentSubmitPending: true,
2020-07-23 14:05:17 +02:00
refunds: {},
2020-08-19 17:25:38 +02:00
merchantPaySig: undefined,
noncePriv: proposal.noncePriv,
noncePub: proposal.noncePub,
};
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => ({
proposals: x.proposals,
purchases: x.purchases,
coins: x.coins,
refreshGroups: x.refreshGroups,
denominations: x.denominations,
}))
.runReadWrite(async (tx) => {
const p = await tx.proposals.get(proposal.proposalId);
2019-12-03 00:52:15 +01:00
if (p) {
p.proposalStatus = ProposalStatus.Accepted;
delete p.lastError;
delete p.retryInfo;
2021-06-09 15:14:17 +02:00
await tx.proposals.put(p);
2019-12-03 00:52:15 +01:00
}
2021-06-09 15:14:17 +02:00
await tx.purchases.put(t);
await applyCoinSpend(ws, tx, coinSelection, `proposal:${t.proposalId}`);
2021-06-09 15:14:17 +02:00
});
2019-12-05 19:38:19 +01:00
ws.notify({
type: NotificationType.ProposalAccepted,
proposalId: proposal.proposalId,
});
return t;
}
async function reportProposalError(
2019-12-05 19:38:19 +01:00
ws: InternalWalletState,
proposalId: string,
err: TalerErrorDetail,
2019-12-05 19:38:19 +01:00
): Promise<void> {
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => ({ proposals: x.proposals }))
.runReadWrite(async (tx) => {
const pr = await tx.proposals.get(proposalId);
if (!pr) {
return;
}
if (!pr.retryInfo) {
logger.error(
`Asked to report an error for a proposal (${proposalId}) that is not active (no retryInfo)`,
);
2021-06-09 15:14:17 +02:00
return;
}
pr.lastError = err;
await tx.proposals.put(pr);
});
ws.notify({ type: NotificationType.ProposalOperationError, error: err });
}
async function setupProposalRetry(
ws: InternalWalletState,
proposalId: string,
options: {
reset: boolean;
},
): Promise<void> {
await ws.db
.mktx((x) => ({ proposals: x.proposals }))
.runReadWrite(async (tx) => {
const pr = await tx.proposals.get(proposalId);
if (!pr) {
return;
}
if (options.reset) {
pr.retryInfo = resetRetryInfo();
} else {
pr.retryInfo = RetryInfo.increment(pr.retryInfo);
}
delete pr.lastError;
await tx.proposals.put(pr);
});
}
async function setupPurchasePayRetry(
ws: InternalWalletState,
proposalId: string,
options: {
reset: boolean;
},
): Promise<void> {
await ws.db
.mktx((x) => ({ purchases: x.purchases }))
.runReadWrite(async (tx) => {
const p = await tx.purchases.get(proposalId);
if (!p) {
2021-06-09 15:14:17 +02:00
return;
}
if (options.reset) {
p.payRetryInfo = resetRetryInfo();
} else {
p.payRetryInfo = RetryInfo.increment(p.payRetryInfo);
}
delete p.lastPayError;
await tx.purchases.put(p);
});
}
async function reportPurchasePayError(
ws: InternalWalletState,
proposalId: string,
err: TalerErrorDetail,
): Promise<void> {
await ws.db
.mktx((x) => ({ purchases: x.purchases }))
.runReadWrite(async (tx) => {
const pr = await tx.purchases.get(proposalId);
if (!pr) {
return;
}
if (!pr.payRetryInfo) {
logger.error(
`purchase record (${proposalId}) reports error, but no retry active`,
);
}
2021-06-09 15:14:17 +02:00
pr.lastPayError = err;
await tx.purchases.put(pr);
});
ws.notify({ type: NotificationType.PayOperationError, error: err });
}
2019-12-03 00:52:15 +01:00
export async function processDownloadProposal(
ws: InternalWalletState,
proposalId: string,
options: {
forceNow?: boolean;
} = {},
2019-12-05 19:38:19 +01:00
): Promise<void> {
const onOpErr = (err: TalerErrorDetail): Promise<void> =>
reportProposalError(ws, proposalId, err);
2019-12-05 19:38:19 +01:00
await guardOperationException(
() => processDownloadProposalImpl(ws, proposalId, options),
2019-12-05 19:38:19 +01:00
onOpErr,
);
}
async function failProposalPermanently(
ws: InternalWalletState,
proposalId: string,
err: TalerErrorDetail,
): Promise<void> {
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => ({ proposals: x.proposals }))
.runReadWrite(async (tx) => {
const p = await tx.proposals.get(proposalId);
if (!p) {
return;
}
2021-06-11 11:15:08 +02:00
delete p.retryInfo;
2021-06-09 15:14:17 +02:00
p.lastError = err;
p.proposalStatus = ProposalStatus.PermanentlyFailed;
2021-06-09 15:14:17 +02:00
await tx.proposals.put(p);
});
}
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(
2021-02-05 12:10:56 +01:00
{ d_ms: 15000 },
1 + purchase.payCoinSelection.coinPubs.length / 5,
);
}
2021-01-18 23:35:41 +01:00
export function extractContractData(
parsedContractTerms: ContractTerms,
contractTermsHash: string,
merchantSig: string,
): WalletContractData {
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);
}
return {
amount,
contractTermsHash: contractTermsHash,
fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "",
merchantBaseUrl: parsedContractTerms.merchant_base_url,
merchantPub: parsedContractTerms.merchant_pub,
merchantSig,
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,
allowedAuditors: parsedContractTerms.auditors.map((x) => ({
auditorBaseUrl: x.url,
auditorPub: x.auditor_pub,
})),
allowedExchanges: parsedContractTerms.exchanges.map((x) => ({
exchangeBaseUrl: x.url,
exchangePub: x.master_pub,
})),
timestamp: parsedContractTerms.timestamp,
wireMethod: parsedContractTerms.wire_method,
wireInfoHash: parsedContractTerms.h_wire,
maxDepositFee: Amounts.parseOrThrow(parsedContractTerms.max_fee),
merchant: parsedContractTerms.merchant,
products: parsedContractTerms.products,
summaryI18n: parsedContractTerms.summary_i18n,
minimumAge: parsedContractTerms.minimum_age,
2021-01-18 23:35:41 +01:00
};
}
2019-12-05 19:38:19 +01:00
async function processDownloadProposalImpl(
ws: InternalWalletState,
proposalId: string,
options: {
forceNow?: boolean;
} = {},
2019-12-03 00:52:15 +01:00
): Promise<void> {
const forceNow = options.forceNow ?? false;
2021-06-09 15:14:17 +02:00
const proposal = await ws.db
.mktx((x) => ({ proposals: x.proposals }))
.runReadOnly(async (tx) => {
return tx.proposals.get(proposalId);
});
2019-12-03 00:52:15 +01:00
if (!proposal) {
return;
}
if (proposal.proposalStatus != ProposalStatus.Downloading) {
2019-12-03 00:52:15 +01:00
return;
}
await setupProposalRetry(ws, proposalId, { reset: forceNow });
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) {
2020-11-08 01:20:50 +01:00
case TalerErrorCode.MERCHANT_POST_ORDERS_ID_CLAIM_ALREADY_CLAIMED:
throw TalerError.fromDetail(
TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED,
{
orderId: proposal.orderId,
claimUrl: orderClaimUrl,
},
"order already claimed (likely by other wallet)",
);
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
2021-04-14 14:36:46 +02:00
// FIXME: Do better error handling, check if the
// contract terms have all their forgettable information still
// present. The wallet should never accept contract terms
// with missing information from the merchant.
const isWellFormed = ContractTermsUtil.validateForgettable(
proposalResp.contract_terms,
2019-12-03 00:52:15 +01:00
);
2021-04-14 14:36:46 +02:00
if (!isWellFormed) {
logger.trace(
`malformed contract terms: ${j2s(proposalResp.contract_terms)}`,
);
const err = makeErrorDetail(
2021-04-14 14:36:46 +02:00
TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED,
{},
"validation for well-formedness failed",
2021-04-14 14:36:46 +02:00
);
await failProposalPermanently(ws, proposalId, err);
throw makePendingOperationFailedError(
err,
TransactionType.Payment,
proposalId,
);
2021-04-14 14:36:46 +02:00
}
const contractTermsHash = ContractTermsUtil.hashContractTerms(
proposalResp.contract_terms,
);
logger.info(`Contract terms hash: ${contractTermsHash}`);
2021-04-14 14:36:46 +02:00
let parsedContractTerms: ContractTerms;
try {
parsedContractTerms = codecForContractTerms().decode(
proposalResp.contract_terms,
);
} catch (e) {
const err = makeErrorDetail(
2021-04-14 14:36:46 +02:00
TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED,
{},
`schema validation failed: ${e}`,
2021-04-14 14:36:46 +02:00
);
await failProposalPermanently(ws, proposalId, err);
throw makePendingOperationFailedError(
err,
TransactionType.Payment,
proposalId,
);
2021-04-14 14:36:46 +02:00
}
2022-03-23 21:24:23 +01:00
const sigValid = await ws.cryptoApi.isValidContractTermsSignature({
contractTermsHash,
2022-03-23 21:24:23 +01:00
merchantPub: parsedContractTerms.merchant_pub,
sig: proposalResp.sig,
});
if (!sigValid) {
const err = makeErrorDetail(
TalerErrorCode.WALLET_CONTRACT_TERMS_SIGNATURE_INVALID,
{
merchantPub: parsedContractTerms.merchant_pub,
orderId: parsedContractTerms.order_id,
},
"merchant's signature on contract terms is invalid",
);
await failProposalPermanently(ws, proposalId, err);
throw makePendingOperationFailedError(
err,
TransactionType.Payment,
proposalId,
);
}
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) {
const err = makeErrorDetail(
TalerErrorCode.WALLET_CONTRACT_TERMS_BASE_URL_MISMATCH,
{
baseUrlForDownload,
baseUrlFromContractTerms,
},
"merchant base URL mismatch",
);
await failProposalPermanently(ws, proposalId, err);
throw makePendingOperationFailedError(
err,
TransactionType.Payment,
proposalId,
);
}
2021-01-18 23:35:41 +01:00
const contractData = extractContractData(
parsedContractTerms,
contractTermsHash,
proposalResp.sig,
);
logger.trace(`extracted contract data: ${j2s(contractData)}`);
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => ({ proposals: x.proposals, purchases: x.purchases }))
.runReadWrite(async (tx) => {
const p = await tx.proposals.get(proposalId);
2019-12-03 00:52:15 +01:00
if (!p) {
return;
}
if (p.proposalStatus !== ProposalStatus.Downloading) {
2019-12-03 00:52:15 +01:00
return;
}
2019-12-16 22:42:10 +01:00
p.download = {
2021-01-18 23:35:41 +01:00
contractData,
2020-12-21 13:23:07 +01:00
contractTermsRaw: 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
) {
2022-01-13 22:01:14 +01:00
const differentPurchase =
await tx.purchases.indexes.byFulfillmentUrl.get(fulfillmentUrl);
2019-12-03 00:52:15 +01:00
if (differentPurchase) {
logger.warn("repurchase detected");
p.proposalStatus = ProposalStatus.Repurchase;
2019-12-03 00:52:15 +01:00
p.repurchaseProposalId = differentPurchase.proposalId;
2021-06-09 15:14:17 +02:00
await tx.proposals.put(p);
2019-12-03 00:52:15 +01:00
return;
}
}
p.proposalStatus = ProposalStatus.Proposed;
2021-06-09 15:14:17 +02:00
await tx.proposals.put(p);
});
2019-12-03 00:52:15 +01:00
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,
2021-09-17 20:48:33 +02:00
noncePriv: string | undefined,
): Promise<string> {
2021-06-09 15:14:17 +02:00
const oldProposal = await ws.db
.mktx((x) => ({ proposals: x.proposals }))
.runReadOnly(async (tx) => {
return tx.proposals.indexes.byUrlAndOrderId.get([
merchantBaseUrl,
orderId,
]);
});
2021-11-27 20:56:58 +01:00
/* If we have already claimed this proposal with the same sessionId
* nonce and claim token, reuse it. */
2021-11-27 20:56:58 +01:00
if (
oldProposal &&
oldProposal.downloadSessionId === sessionId &&
(!noncePriv || oldProposal.noncePriv === noncePriv) &&
oldProposal.claimToken === claimToken
) {
2019-12-03 00:52:15 +01:00
await processDownloadProposal(ws, oldProposal.proposalId);
return oldProposal.proposalId;
}
2022-03-23 21:24:23 +01:00
let noncePair: EddsaKeypair;
if (noncePriv) {
noncePair = {
priv: noncePriv,
pub: (await ws.cryptoApi.eddsaGetPublic({ priv: noncePriv })).pub,
};
2022-03-23 21:24:23 +01:00
} else {
noncePair = await ws.cryptoApi.createEddsaKeypair({});
}
const { priv, pub } = noncePair;
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,
2022-03-18 15:32:41 +01:00
timestamp: AbsoluteTime.toTimestamp(AbsoluteTime.now()),
2019-12-06 12:47:28 +01:00
merchantBaseUrl,
orderId,
proposalId: proposalId,
proposalStatus: ProposalStatus.Downloading,
2019-12-03 00:52:15 +01:00
repurchaseProposalId: undefined,
retryInfo: resetRetryInfo(),
2019-12-05 19:38:19 +01:00
lastError: undefined,
downloadSessionId: sessionId,
};
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => ({ proposals: x.proposals }))
.runReadWrite(async (tx) => {
2021-06-09 16:47:45 +02:00
const existingRecord = await tx.proposals.indexes.byUrlAndOrderId.get([
2021-06-09 15:14:17 +02:00
merchantBaseUrl,
orderId,
]);
if (existingRecord) {
// Created concurrently
return;
}
await tx.proposals.put(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> {
2022-03-18 15:32:41 +01:00
const now = AbsoluteTime.toTimestamp(AbsoluteTime.now());
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => ({ purchases: x.purchases }))
.runReadWrite(async (tx) => {
const purchase = await tx.purchases.get(proposalId);
2020-08-19 17:25:38 +02:00
2021-06-09 15:14:17 +02:00
if (!purchase) {
logger.warn("purchase does not exist anymore");
return;
2020-08-19 17:25:38 +02:00
}
2021-06-09 15:14:17 +02:00
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 = resetRetryInfo();
2021-06-09 15:14:17 +02:00
purchase.merchantPaySig = paySig;
2022-05-14 23:09:33 +02:00
const protoAr = purchase.download.contractData.autoRefund;
if (protoAr) {
const ar = Duration.fromTalerProtocolDuration(protoAr);
logger.info("auto_refund present");
purchase.refundQueryRequested = true;
purchase.refundStatusRetryInfo = resetRetryInfo();
purchase.lastRefundStatusError = undefined;
purchase.autoRefundDeadline = AbsoluteTime.toTimestamp(
AbsoluteTime.addDuration(AbsoluteTime.now(), ar),
);
2021-06-09 15:14:17 +02:00
}
await tx.purchases.put(purchase);
});
2020-08-19 17:25:38 +02:00
}
async function storePayReplaySuccess(
ws: InternalWalletState,
proposalId: string,
sessionId: string | undefined,
): Promise<void> {
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => ({ purchases: x.purchases }))
.runReadWrite(async (tx) => {
const purchase = await tx.purchases.get(proposalId);
2020-08-19 17:25:38 +02:00
2021-06-09 15:14:17 +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 = resetRetryInfo();
2021-06-09 15:14:17 +02:00
purchase.lastSessionId = sessionId;
await tx.purchases.put(purchase);
});
2020-08-19 17:25:38 +02:00
}
/**
* Handle a 409 Conflict response from the merchant.
*
* We do this by going through the coin history provided by the exchange and
* (1) verifying the signatures from the exchange
* (2) adjusting the remaining coin value and refreshing it
* (3) re-do coin selection with the bad coin removed
*/
async function handleInsufficientFunds(
ws: InternalWalletState,
proposalId: string,
err: TalerErrorDetail,
): Promise<void> {
logger.trace("handling insufficient funds, trying to re-select coins");
2021-06-09 15:14:17 +02:00
const proposal = await ws.db
.mktx((x) => ({ purchaes: x.purchases }))
.runReadOnly(async (tx) => {
return tx.purchaes.get(proposalId);
});
if (!proposal) {
return;
}
const brokenCoinPub = (err as any).coin_pub;
const exchangeReply = (err as any).exchange_reply;
if (
exchangeReply.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS
) {
// FIXME: set as failed
if (logger.shouldLogTrace()) {
logger.trace("got exchange error reply (see below)");
logger.trace(j2s(exchangeReply));
}
throw Error(`unable to handle /pay error response (${exchangeReply.code})`);
}
logger.trace(`got error details: ${j2s(err)}`);
const { contractData } = proposal.download;
const candidates = await getCandidatePayCoins(ws, {
allowedAuditors: contractData.allowedAuditors,
allowedExchanges: contractData.allowedExchanges,
amount: contractData.amount,
maxDepositFee: contractData.maxDepositFee,
maxWireFee: contractData.maxWireFee,
timestamp: contractData.timestamp,
wireFeeAmortization: contractData.wireFeeAmortization,
wireMethod: contractData.wireMethod,
});
const prevPayCoins: PreviousPayCoins = [];
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => ({ coins: x.coins, denominations: x.denominations }))
.runReadOnly(async (tx) => {
for (let i = 0; i < proposal.payCoinSelection.coinPubs.length; i++) {
const coinPub = proposal.payCoinSelection.coinPubs[i];
if (coinPub === brokenCoinPub) {
continue;
}
const contrib = proposal.payCoinSelection.coinContributions[i];
const coin = await tx.coins.get(coinPub);
if (!coin) {
continue;
}
const denom = await tx.denominations.get([
coin.exchangeBaseUrl,
coin.denomPubHash,
]);
if (!denom) {
continue;
}
prevPayCoins.push({
coinPub,
contribution: contrib,
exchangeBaseUrl: coin.exchangeBaseUrl,
feeDeposit: denom.feeDeposit,
});
}
});
const res = selectPayCoins({
candidates,
contractTermsAmount: contractData.amount,
depositFeeLimit: contractData.maxDepositFee,
wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
wireFeeLimit: contractData.maxWireFee,
prevPayCoins,
requiredMinimumAge: contractData.minimumAge,
});
if (!res) {
logger.trace("insufficient funds for coin re-selection");
return;
}
logger.trace("re-selected coins");
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => ({
purchases: x.purchases,
coins: x.coins,
denominations: x.denominations,
refreshGroups: x.refreshGroups,
}))
.runReadWrite(async (tx) => {
const p = await tx.purchases.get(proposalId);
if (!p) {
return;
}
p.payCoinSelection = res;
p.payCoinSelectionUid = encodeCrock(getRandomBytes(32));
p.coinDepositPermissions = undefined;
2021-06-09 15:14:17 +02:00
await tx.purchases.put(p);
await applyCoinSpend(ws, tx, res, `proposal:${p.proposalId}`);
2021-06-09 15:14:17 +02:00
});
}
async function unblockBackup(
ws: InternalWalletState,
proposalId: string,
): Promise<void> {
await ws.db
.mktx((x) => ({ backupProviders: x.backupProviders }))
.runReadWrite(async (tx) => {
await tx.backupProviders.indexes.byPaymentProposalId
.iter(proposalId)
.forEachAsync(async (bp) => {
if (bp.state.tag === BackupProviderStateTag.Retrying) {
bp.state = {
tag: BackupProviderStateTag.Ready,
2022-03-18 15:32:41 +01:00
nextBackupTimestamp: TalerProtocolTimestamp.now(),
};
}
});
});
}
export async function checkPaymentByProposalId(
ws: InternalWalletState,
proposalId: string,
sessionId?: string,
): Promise<PreparePayResult> {
2021-06-09 15:14:17 +02:00
let proposal = await ws.db
.mktx((x) => ({ proposals: x.proposals }))
.runReadOnly(async (tx) => {
return tx.proposals.get(proposalId);
});
2019-12-03 00:52:15 +01:00
if (!proposal) {
throw Error(`could not get proposal ${proposalId}`);
}
if (proposal.proposalStatus === ProposalStatus.Repurchase) {
2019-12-03 00:52:15 +01:00
const existingProposalId = proposal.repurchaseProposalId;
if (!existingProposalId) {
throw Error("invalid proposal state");
}
logger.trace("using existing purchase for same product");
2021-06-09 15:14:17 +02:00
proposal = await ws.db
.mktx((x) => ({ proposals: x.proposals }))
.runReadOnly(async (tx) => {
return tx.proposals.get(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
2021-04-27 23:42:25 +02:00
// First check if we already paid for it.
2021-06-09 15:14:17 +02:00
const purchase = await ws.db
.mktx((x) => ({ purchases: x.purchases }))
.runReadOnly(async (tx) => {
return tx.purchases.get(proposalId);
});
if (!purchase) {
// If not already paid, check if we could pay for it.
const candidates = await getCandidatePayCoins(ws, {
allowedAuditors: contractData.allowedAuditors,
allowedExchanges: contractData.allowedExchanges,
amount: contractData.amount,
maxDepositFee: contractData.maxDepositFee,
maxWireFee: contractData.maxWireFee,
timestamp: contractData.timestamp,
wireFeeAmortization: contractData.wireFeeAmortization,
wireMethod: contractData.wireMethod,
});
const res = selectPayCoins({
candidates,
contractTermsAmount: contractData.amount,
depositFeeLimit: contractData.maxDepositFee,
wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
wireFeeLimit: contractData.maxWireFee,
prevPayCoins: [],
requiredMinimumAge: contractData.minimumAge,
});
if (!res) {
2020-07-30 13:58:09 +02:00
logger.info("not confirming payment, insufficient coins");
return {
status: PreparePayResultType.InsufficientBalance,
2020-12-21 13:23:07 +01:00
contractTerms: d.contractTermsRaw,
proposalId: proposal.proposalId,
2021-09-17 20:48:33 +02:00
noncePriv: proposal.noncePriv,
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,
2020-12-21 13:23:07 +01:00
contractTerms: d.contractTermsRaw,
proposalId: proposal.proposalId,
2021-09-17 20:48:33 +02:00
noncePriv: proposal.noncePriv,
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),
2021-08-06 11:45:08 +02:00
contractTermsHash: d.contractData.contractTermsHash,
};
}
if (purchase.lastSessionId !== sessionId) {
2020-07-29 19:40:41 +02:00
logger.trace(
"automatically re-submitting payment with different session ID",
);
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => ({ purchases: x.purchases }))
.runReadWrite(async (tx) => {
const p = await tx.purchases.get(proposalId);
if (!p) {
return;
}
p.lastSessionId = sessionId;
p.paymentSubmitPending = true;
2021-06-09 15:14:17 +02:00
await tx.purchases.put(p);
});
const r = await processPurchasePay(ws, proposalId, { forceNow: true });
2020-08-11 14:02:11 +02:00
if (r.type !== ConfirmPayResultType.Done) {
throw Error("submitting pay failed");
}
return {
status: PreparePayResultType.AlreadyConfirmed,
2020-12-21 13:23:07 +01:00
contractTerms: purchase.download.contractTermsRaw,
contractTermsHash: purchase.download.contractData.contractTermsHash,
paid: true,
2020-12-21 13:23:07 +01:00
amountRaw: Amounts.stringify(purchase.download.contractData.amount),
2020-09-08 17:15:33 +02:00
amountEffective: Amounts.stringify(purchase.totalPayCost),
proposalId,
};
} else if (!purchase.timestampFirstSuccessfulPay) {
return {
status: PreparePayResultType.AlreadyConfirmed,
2020-12-21 13:23:07 +01:00
contractTerms: purchase.download.contractTermsRaw,
contractTermsHash: purchase.download.contractData.contractTermsHash,
paid: false,
2020-12-21 13:23:07 +01:00
amountRaw: Amounts.stringify(purchase.download.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,
2020-12-21 13:23:07 +01:00
contractTerms: purchase.download.contractTermsRaw,
contractTermsHash: purchase.download.contractData.contractTermsHash,
paid,
2020-12-21 13:23:07 +01:00
amountRaw: Amounts.stringify(purchase.download.contractData.amount),
2020-09-08 17:15:33 +02:00
amountEffective: Amounts.stringify(purchase.totalPayCost),
2020-12-21 13:23:07 +01:00
...(paid ? { nextUrl: purchase.download.contractData.orderId } : {}),
proposalId,
};
}
}
/**
* 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 preparePayForUri(
ws: InternalWalletState,
talerPayUri: string,
): Promise<PreparePayResult> {
const uriResult = parsePayUri(talerPayUri);
if (!uriResult) {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_INVALID_TALER_PAY_URI,
{
talerPayUri,
},
`invalid taler://pay URI (${talerPayUri})`,
);
}
let proposalId = await startDownloadProposal(
ws,
uriResult.merchantBaseUrl,
uriResult.orderId,
uriResult.sessionId,
uriResult.claimToken,
2021-09-17 20:48:33 +02:00
uriResult.noncePriv,
);
return checkPaymentByProposalId(ws, proposalId, uriResult.sessionId);
}
2021-01-04 13:30:38 +01:00
/**
* Generate deposit permissions for a purchase.
*
* Accesses the database and the crypto worker.
*/
2021-01-18 23:35:41 +01:00
export async function generateDepositPermissions(
2021-01-04 13:30:38 +01:00
ws: InternalWalletState,
payCoinSel: PayCoinSelection,
contractData: WalletContractData,
): Promise<CoinDepositPermission[]> {
const depositPermissions: CoinDepositPermission[] = [];
2021-06-09 15:14:17 +02:00
const coinWithDenom: Array<{
coin: CoinRecord;
denom: DenominationRecord;
}> = [];
await ws.db
.mktx((x) => ({ coins: x.coins, denominations: x.denominations }))
.runReadOnly(async (tx) => {
for (let i = 0; i < payCoinSel.coinPubs.length; i++) {
const coin = await tx.coins.get(payCoinSel.coinPubs[i]);
if (!coin) {
throw Error("can't pay, allocated coin not found anymore");
}
const denom = await tx.denominations.get([
coin.exchangeBaseUrl,
coin.denomPubHash,
]);
if (!denom) {
throw Error(
"can't pay, denomination of allocated coin not found anymore",
);
}
coinWithDenom.push({ coin, denom });
}
});
2021-01-04 13:30:38 +01:00
for (let i = 0; i < payCoinSel.coinPubs.length; i++) {
2021-06-09 15:14:17 +02:00
const { coin, denom } = coinWithDenom[i];
2021-11-27 20:56:58 +01:00
let wireInfoHash: string;
wireInfoHash = contractData.wireInfoHash;
logger.trace(
`signing deposit permission for coin with acp=${j2s(
coin.ageCommitmentProof,
)}`,
);
2021-01-04 13:30:38 +01:00
const dp = await ws.cryptoApi.signDepositPermission({
coinPriv: coin.coinPriv,
coinPub: coin.coinPub,
contractTermsHash: contractData.contractTermsHash,
denomPubHash: coin.denomPubHash,
denomKeyType: denom.denomPub.cipher,
2021-01-04 13:30:38 +01:00
denomSig: coin.denomSig,
exchangeBaseUrl: coin.exchangeBaseUrl,
feeDeposit: denom.feeDeposit,
merchantPub: contractData.merchantPub,
refundDeadline: contractData.refundDeadline,
spendAmount: payCoinSel.coinContributions[i],
timestamp: contractData.timestamp,
2021-11-27 20:56:58 +01:00
wireInfoHash,
ageCommitmentProof: coin.ageCommitmentProof,
requiredMinimumAge: contractData.minimumAge,
2021-01-04 13:30:38 +01:00
});
depositPermissions.push(dp);
}
return depositPermissions;
}
/**
* Add a contract to the wallet and sign coins, and send them.
*/
export async function confirmPay(
ws: InternalWalletState,
proposalId: string,
2021-01-07 19:50:53 +01:00
sessionIdOverride?: string,
): Promise<ConfirmPayResult> {
logger.trace(
`executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`,
);
2021-06-09 15:14:17 +02:00
const proposal = await ws.db
.mktx((x) => ({ proposals: x.proposals }))
.runReadOnly(async (tx) => {
return tx.proposals.get(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");
}
2021-06-09 15:14:17 +02:00
const existingPurchase = await ws.db
.mktx((x) => ({ purchases: x.purchases }))
.runReadWrite(async (tx) => {
const purchase = await tx.purchases.get(proposalId);
if (
purchase &&
sessionIdOverride !== undefined &&
sessionIdOverride != purchase.lastSessionId
) {
logger.trace(`changing session ID to ${sessionIdOverride}`);
purchase.lastSessionId = sessionIdOverride;
purchase.paymentSubmitPending = true;
await tx.purchases.put(purchase);
}
return purchase;
});
2021-06-09 15:14:17 +02:00
if (existingPurchase) {
logger.trace("confirmPay: submitting payment for existing purchase");
return await processPurchasePay(ws, proposalId, { forceNow: true });
}
logger.trace("confirmPay: purchase record does not exist yet");
const contractData = d.contractData;
const candidates = await getCandidatePayCoins(ws, {
allowedAuditors: contractData.allowedAuditors,
allowedExchanges: contractData.allowedExchanges,
amount: contractData.amount,
maxDepositFee: contractData.maxDepositFee,
maxWireFee: contractData.maxWireFee,
timestamp: contractData.timestamp,
wireFeeAmortization: contractData.wireFeeAmortization,
wireMethod: contractData.wireMethod,
});
const res = selectPayCoins({
candidates,
contractTermsAmount: contractData.amount,
depositFeeLimit: contractData.maxDepositFee,
wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
wireFeeLimit: contractData.maxWireFee,
prevPayCoins: [],
requiredMinimumAge: contractData.minimumAge,
});
logger.trace("coin selection result", res);
if (!res) {
// Should not happen, since checkPay should be called first
// FIXME: Actually, this should be handled gracefully,
// and the status should be stored in the DB.
2020-07-30 13:58:09 +02:00
logger.warn("not confirming payment, insufficient coins");
throw Error("insufficient balance");
}
2021-01-04 13:30:38 +01:00
const depositPermissions = await generateDepositPermissions(
ws,
res,
d.contractData,
);
2021-06-09 15:14:17 +02:00
await recordConfirmPay(
2019-12-15 21:40:06 +01:00
ws,
proposal,
2019-12-25 19:11:20 +01:00
res,
depositPermissions,
sessionIdOverride,
2019-12-15 21:40:06 +01:00
);
return await processPurchasePay(ws, proposalId, { forceNow: true });
}
2019-12-03 14:40:05 +01:00
export async function processPurchasePay(
2019-12-05 19:38:19 +01:00
ws: InternalWalletState,
proposalId: string,
options: {
forceNow?: boolean;
} = {},
): Promise<ConfirmPayResult> {
const onOpErr = (e: TalerErrorDetail): Promise<void> =>
reportPurchasePayError(ws, proposalId, e);
return await guardOperationException(
() => processPurchasePayImpl(ws, proposalId, options),
2019-12-05 19:38:19 +01:00
onOpErr,
);
}
async function processPurchasePayImpl(
2019-12-05 19:38:19 +01:00
ws: InternalWalletState,
proposalId: string,
options: {
forceNow?: boolean;
} = {},
): Promise<ConfirmPayResult> {
const forceNow = options.forceNow ?? false;
2021-06-09 15:14:17 +02:00
const purchase = await ws.db
.mktx((x) => ({ purchases: x.purchases }))
.runReadOnly(async (tx) => {
return tx.purchases.get(proposalId);
});
2019-12-05 19:38:19 +01:00
if (!purchase) {
return {
type: ConfirmPayResultType.Pending,
lastError: {
// FIXME: allocate more specific error code
code: TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
hint: `trying to pay for purchase that is not in the database`,
proposalId: proposalId,
},
};
2019-12-05 19:38:19 +01:00
}
if (!purchase.paymentSubmitPending) {
return {
type: ConfirmPayResultType.Pending,
lastError: purchase.lastPayError,
};
}
await setupPurchasePayRetry(ws, proposalId, { reset: forceNow });
logger.trace(`processing purchase pay ${proposalId}`);
const sessionId = purchase.lastSessionId;
logger.trace("paying with session ID", sessionId);
if (!purchase.merchantPaySig) {
const payUrl = new URL(
`orders/${purchase.download.contractData.orderId}/pay`,
purchase.download.contractData.merchantBaseUrl,
).href;
let depositPermissions: CoinDepositPermission[];
if (purchase.coinDepositPermissions) {
depositPermissions = purchase.coinDepositPermissions;
} else {
// FIXME: also cache!
depositPermissions = await generateDepositPermissions(
ws,
purchase.payCoinSelection,
purchase.download.contractData,
);
}
const reqBody = {
coins: depositPermissions,
session_id: purchase.lastSessionId,
};
logger.trace(
"making pay request ... ",
JSON.stringify(reqBody, undefined, 2),
);
const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
ws.http.postJson(payUrl, reqBody, {
timeout: getPayRequestTimeout(purchase),
}),
);
logger.trace(`got resp ${JSON.stringify(resp)}`);
// Hide transient errors.
if (
(purchase.payRetryInfo?.retryCounter ?? 0) <= 5 &&
resp.status >= 500 &&
resp.status <= 599
) {
logger.trace("treating /pay error as transient");
const err = makeErrorDetail(
TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
getHttpResponseErrorDetails(resp),
"/pay failed",
);
return {
type: ConfirmPayResultType.Pending,
lastError: err,
};
}
if (resp.status === HttpStatusCode.BadRequest) {
const errDetails = await readUnexpectedResponseDetails(resp);
logger.warn("unexpected 400 response for /pay");
logger.warn(j2s(errDetails));
await ws.db
.mktx((x) => ({ purchases: x.purchases }))
.runReadWrite(async (tx) => {
const purch = await tx.purchases.get(proposalId);
if (!purch) {
return;
}
purch.payFrozen = true;
purch.lastPayError = errDetails;
delete purch.payRetryInfo;
await tx.purchases.put(purch);
});
throw makePendingOperationFailedError(
errDetails,
TransactionType.Payment,
proposalId,
);
}
if (resp.status === HttpStatusCode.Conflict) {
const err = await readTalerErrorResponse(resp);
if (
err.code ===
TalerErrorCode.MERCHANT_POST_ORDERS_ID_PAY_INSUFFICIENT_FUNDS
) {
// Do this in the background, as it might take some time
handleInsufficientFunds(ws, proposalId, err).catch(async (e) => {
reportPurchasePayError(ws, proposalId, {
code: TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
message: "unexpected exception",
hint: "unexpected exception",
details: {
exception: e.toString(),
},
});
});
return {
type: ConfirmPayResultType.Pending,
// FIXME: should we return something better here?
lastError: err,
};
}
}
const merchantResp = await readSuccessResponseJsonOrThrow(
resp,
codecForMerchantPayResponse(),
);
logger.trace("got success from pay URL", merchantResp);
const merchantPub = purchase.download.contractData.merchantPub;
2022-03-23 21:24:23 +01:00
const { valid } = await ws.cryptoApi.isValidPaymentSignature({
contractHash: purchase.download.contractData.contractTermsHash,
merchantPub,
2022-03-23 21:24:23 +01:00
sig: merchantResp.sig,
});
if (!valid) {
logger.error("merchant payment signature invalid");
// FIXME: properly display error
throw Error("merchant payment signature invalid");
}
await storeFirstPaySuccess(ws, proposalId, sessionId, merchantResp.sig);
await unblockBackup(ws, proposalId);
} else {
const payAgainUrl = new URL(
`orders/${purchase.download.contractData.orderId}/paid`,
purchase.download.contractData.merchantBaseUrl,
).href;
const reqBody = {
sig: purchase.merchantPaySig,
h_contract: purchase.download.contractData.contractTermsHash,
session_id: sessionId ?? "",
};
const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
ws.http.postJson(payAgainUrl, reqBody),
);
// Hide transient errors.
if (
(purchase.payRetryInfo?.retryCounter ?? 0) <= 5 &&
resp.status >= 500 &&
resp.status <= 599
) {
const err = makeErrorDetail(
TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
getHttpResponseErrorDetails(resp),
"/paid failed",
);
return {
type: ConfirmPayResultType.Pending,
lastError: err,
};
}
if (resp.status !== 204) {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
getHttpResponseErrorDetails(resp),
"/paid failed",
);
}
await storePayReplaySuccess(ws, proposalId, sessionId);
await unblockBackup(ws, proposalId);
}
ws.notify({
type: NotificationType.PayOperationSuccess,
proposalId: purchase.proposalId,
});
return {
type: ConfirmPayResultType.Done,
contractTerms: purchase.download.contractTermsRaw,
};
}
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> {
2021-06-09 15:26:18 +02:00
const success = await ws.db
.mktx((x) => ({ proposals: x.proposals }))
.runReadWrite(async (tx) => {
2021-06-09 15:14:17 +02:00
const proposal = await tx.proposals.get(proposalId);
2019-12-25 19:11:20 +01:00
if (!proposal) {
logger.trace(`proposal ${proposalId} not found, won't refuse proposal`);
return false;
}
if (proposal.proposalStatus !== ProposalStatus.Proposed) {
2019-12-25 19:11:20 +01:00
return false;
}
proposal.proposalStatus = ProposalStatus.Refused;
2021-06-09 15:14:17 +02:00
await tx.proposals.put(proposal);
2019-12-25 19:11:20 +01:00
return true;
2021-06-09 15:26:18 +02:00
});
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
}