2019-12-02 00:42:40 +01:00
|
|
|
/*
|
|
|
|
This file is part of GNU Taler
|
2022-03-08 23:09:20 +01:00
|
|
|
(C) 2019-2022 Taler Systems S.A.
|
2019-12-02 00:42:40 +01:00
|
|
|
|
|
|
|
GNU Taler is free software; you can redistribute it and/or modify it under the
|
|
|
|
terms of the GNU General Public License as published by the Free Software
|
|
|
|
Foundation; either version 3, or (at your option) any later version.
|
|
|
|
|
|
|
|
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
|
|
|
|
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
|
|
|
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
|
|
|
|
|
|
|
You should have received a copy of the GNU General Public License along with
|
|
|
|
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
|
|
|
|
*/
|
|
|
|
|
2019-12-15 19:04:14 +01:00
|
|
|
/**
|
|
|
|
* Implementation of the payment operation, including downloading and
|
|
|
|
* claiming of proposals.
|
|
|
|
*
|
|
|
|
* @author Florian Dold
|
|
|
|
*/
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Imports.
|
|
|
|
*/
|
2021-03-27 19:35:44 +01:00
|
|
|
import {
|
2022-03-22 21:16:38 +01:00
|
|
|
AbsoluteTime,
|
2021-03-27 19:35:44 +01:00
|
|
|
AmountJson,
|
2022-03-08 23:09:20 +01:00
|
|
|
Amounts,
|
|
|
|
codecForContractTerms,
|
|
|
|
codecForMerchantPayResponse,
|
|
|
|
codecForProposal,
|
|
|
|
CoinDepositPermission,
|
|
|
|
ConfirmPayResult,
|
|
|
|
ConfirmPayResultType,
|
|
|
|
ContractTerms,
|
2022-07-12 17:41:14 +02:00
|
|
|
ContractTermsUtil,
|
2022-03-08 23:09:20 +01:00
|
|
|
Duration,
|
2021-03-27 19:35:44 +01:00
|
|
|
durationMax,
|
|
|
|
durationMin,
|
2022-03-08 23:09:20 +01:00
|
|
|
durationMul,
|
|
|
|
encodeCrock,
|
2022-06-10 13:03:47 +02:00
|
|
|
ForcedCoinSel,
|
2022-03-08 23:09:20 +01:00
|
|
|
getRandomBytes,
|
|
|
|
HttpStatusCode,
|
|
|
|
j2s,
|
|
|
|
Logger,
|
|
|
|
NotificationType,
|
|
|
|
parsePayUri,
|
2022-06-10 13:03:47 +02:00
|
|
|
PayCoinSelection,
|
2022-03-08 23:09:20 +01:00
|
|
|
PreparePayResult,
|
|
|
|
PreparePayResultType,
|
|
|
|
RefreshReason,
|
|
|
|
TalerErrorCode,
|
2022-03-22 21:16:38 +01:00
|
|
|
TalerErrorDetail,
|
2022-03-18 15:32:41 +01:00
|
|
|
TalerProtocolTimestamp,
|
2022-03-22 21:16:38 +01:00
|
|
|
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,
|
2021-06-25 13:27:06 +02:00
|
|
|
BackupProviderStateTag,
|
2021-06-14 16:08:58 +02:00
|
|
|
CoinRecord,
|
|
|
|
CoinStatus,
|
|
|
|
DenominationRecord,
|
|
|
|
ProposalRecord,
|
|
|
|
ProposalStatus,
|
|
|
|
PurchaseRecord,
|
|
|
|
WalletContractData,
|
2022-03-08 23:09:20 +01:00
|
|
|
WalletStoresV1,
|
2021-06-14 16:08:58 +02:00
|
|
|
} from "../db.js";
|
2022-01-16 21:47:43 +01:00
|
|
|
import {
|
2022-03-22 21:16:38 +01:00
|
|
|
makeErrorDetail,
|
|
|
|
makePendingOperationFailedError,
|
|
|
|
TalerError,
|
2022-01-16 21:47:43 +01:00
|
|
|
} from "../errors.js";
|
|
|
|
import {
|
2022-03-08 23:09:20 +01:00
|
|
|
AvailableCoinInfo,
|
|
|
|
CoinCandidateSelection,
|
|
|
|
PreviousPayCoins,
|
2022-06-10 13:03:47 +02:00
|
|
|
selectForcedPayCoins,
|
2022-03-08 23:09:20 +01:00
|
|
|
selectPayCoins,
|
2022-01-16 21:47:43 +01:00
|
|
|
} from "../util/coinSelection.js";
|
2021-06-14 16:08:58 +02:00
|
|
|
import {
|
|
|
|
getHttpResponseErrorDetails,
|
|
|
|
readSuccessResponseJsonOrErrorCode,
|
|
|
|
readSuccessResponseJsonOrThrow,
|
|
|
|
readTalerErrorResponse,
|
2021-08-24 15:08:34 +02:00
|
|
|
readUnexpectedResponseDetails,
|
2022-03-08 23:09:20 +01:00
|
|
|
throwUnexpectedRequestError,
|
2021-06-14 16:08:58 +02:00
|
|
|
} from "../util/http.js";
|
2022-01-16 21:47:43 +01:00
|
|
|
import { GetReadWriteAccess } from "../util/query.js";
|
2022-05-19 11:01:07 +02:00
|
|
|
import { RetryInfo } from "../util/retries.js";
|
2022-01-16 21:47:43 +01:00
|
|
|
import { getExchangeDetails } from "./exchanges.js";
|
|
|
|
import { createRefreshGroup, getTotalRefreshCost } from "./refresh.js";
|
2022-03-23 13:11:36 +01:00
|
|
|
import { guardOperationException } from "./common.js";
|
2022-03-23 21:24:23 +01:00
|
|
|
import { EddsaKeypair } from "../crypto/cryptoImplementation.js";
|
2019-12-02 00:42:40 +01:00
|
|
|
|
2019-12-25 21:47:57 +01:00
|
|
|
/**
|
|
|
|
* Logger.
|
|
|
|
*/
|
|
|
|
const logger = new Logger("pay.ts");
|
|
|
|
|
2019-12-02 00:42:40 +01:00
|
|
|
/**
|
2019-12-25 19:11:20 +01:00
|
|
|
* Compute the total cost of a payment to the customer.
|
2020-03-30 12:39:32 +02:00
|
|
|
*
|
2019-12-25 21:47:57 +01:00
|
|
|
* This includes the amount taken by the merchant, fees (wire/deposit) contributed
|
|
|
|
* by the customer, refreshing fees, fees for withdraw-after-refresh and "trimmings"
|
|
|
|
* of coins that are too small to spend.
|
2019-12-25 19:11:20 +01:00
|
|
|
*/
|
|
|
|
export async function getTotalPaymentCost(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
pcs: PayCoinSelection,
|
2020-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) => {
|
2022-03-23 18:20:18 +01:00
|
|
|
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
|
2021-12-01 18:07:55 +01:00
|
|
|
.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
|
|
|
}
|
|
|
|
|
2021-06-22 13:52:28 +02:00
|
|
|
function isSpendableCoin(coin: CoinRecord, denom: DenominationRecord): boolean {
|
2020-09-03 17:08:26 +02:00
|
|
|
if (coin.suspended) {
|
|
|
|
return false;
|
|
|
|
}
|
2021-08-23 22:28:36 +02:00
|
|
|
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-03-15 13:43:53 +01:00
|
|
|
|
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;
|
2022-04-19 17:12:43 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
* 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
|
|
|
}
|
|
|
|
|
2019-12-02 00:42:40 +01:00
|
|
|
/**
|
2021-03-15 13:43:53 +01:00
|
|
|
* Get candidate coins. From these candidate coins,
|
|
|
|
* the actual contributions will be computed later.
|
2019-12-25 19:11:20 +01:00
|
|
|
*
|
2021-03-15 13:43:53 +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.
|
2019-12-02 00:42:40 +01:00
|
|
|
*/
|
2021-03-15 13:43:53 +01:00
|
|
|
export async function getCandidatePayCoins(
|
2019-12-02 00:42:40 +01:00
|
|
|
ws: InternalWalletState,
|
2021-01-18 23:35:41 +01:00
|
|
|
req: CoinSelectionRequest,
|
2021-03-15 13:43:53 +01:00
|
|
|
): Promise<CoinCandidateSelection> {
|
|
|
|
const candidateCoins: AvailableCoinInfo[] = [];
|
|
|
|
const wireFeesPerExchange: Record<string, AmountJson> = {};
|
2019-12-02 00:42:40 +01:00
|
|
|
|
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;
|
|
|
|
}
|
2019-12-02 00:42:40 +01:00
|
|
|
|
2021-06-09 15:14:17 +02:00
|
|
|
// is the exchange explicitly allowed?
|
|
|
|
for (const allowedExchange of req.allowedExchanges) {
|
|
|
|
if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) {
|
2019-12-02 00:42:40 +01:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
}
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
|
|
|
|
2021-06-09 15:14:17 +02:00
|
|
|
if (!isOkay) {
|
|
|
|
continue;
|
|
|
|
}
|
2019-12-02 00:42:40 +01:00
|
|
|
|
2021-06-09 15:14:17 +02:00
|
|
|
const coins = await tx.coins.indexes.byBaseUrl
|
|
|
|
.iter(exchange.baseUrl)
|
|
|
|
.toArray();
|
2019-12-02 00:42:40 +01:00
|
|
|
|
2021-06-09 15:14:17 +02:00
|
|
|
if (!coins || coins.length === 0) {
|
|
|
|
continue;
|
|
|
|
}
|
2019-12-02 00:42:40 +01:00
|
|
|
|
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,
|
2022-06-10 13:03:47 +02:00
|
|
|
value: denom.value,
|
2021-06-09 15:14:17 +02:00
|
|
|
coinPub: coin.coinPub,
|
2022-03-10 16:30:24 +01:00
|
|
|
denomPub: denom.denomPub,
|
2021-06-09 15:14:17 +02:00
|
|
|
feeDeposit: denom.feeDeposit,
|
|
|
|
exchangeBaseUrl: denom.exchangeBaseUrl,
|
2022-04-29 21:05:17 +02:00
|
|
|
ageCommitmentProof: coin.ageCommitmentProof,
|
2021-06-09 15:14:17 +02:00
|
|
|
});
|
|
|
|
}
|
2019-12-02 00:42:40 +01: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;
|
|
|
|
}
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
2021-06-09 15:14:17 +02:00
|
|
|
});
|
2021-03-15 13:43:53 +01:00
|
|
|
|
|
|
|
return {
|
|
|
|
candidateCoins,
|
|
|
|
wireFeesPerExchange,
|
|
|
|
};
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
|
|
|
|
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,
|
2021-06-22 18:43:11 +02:00
|
|
|
allocationId: string,
|
2022-01-16 21:47:43 +01:00
|
|
|
): 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");
|
|
|
|
}
|
2021-06-22 18:43:11 +02:00
|
|
|
const contrib = coinSelection.coinContributions[i];
|
2021-04-07 19:29:51 +02:00
|
|
|
if (coin.status !== CoinStatus.Fresh) {
|
2021-06-22 18:43:11 +02:00
|
|
|
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)");
|
|
|
|
}
|
2021-06-23 10:18:40 +02:00
|
|
|
continue;
|
2021-04-07 19:29:51 +02:00
|
|
|
}
|
2021-01-18 23:35:41 +01:00
|
|
|
coin.status = CoinStatus.Dormant;
|
2021-06-22 18:43:11 +02:00
|
|
|
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);
|
|
|
|
}
|
|
|
|
|
2019-12-02 00:42:40 +01:00
|
|
|
/**
|
|
|
|
* Record all information that is necessary to
|
|
|
|
* pay for a proposal in the wallet's database.
|
|
|
|
*/
|
|
|
|
async function recordConfirmPay(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
proposal: ProposalRecord,
|
2019-12-25 19:11:20 +01:00
|
|
|
coinSelection: PayCoinSelection,
|
|
|
|
coinDepositPermissions: CoinDepositPermission[],
|
2019-12-10 12:22:29 +01:00
|
|
|
sessionIdOverride: string | undefined,
|
2019-12-02 00:42:40 +01:00
|
|
|
): Promise<PurchaseRecord> {
|
2019-12-03 00:52:15 +01:00
|
|
|
const d = proposal.download;
|
|
|
|
if (!d) {
|
|
|
|
throw Error("proposal is in invalid state");
|
|
|
|
}
|
2019-12-10 12:22:29 +01:00
|
|
|
let sessionId;
|
|
|
|
if (sessionIdOverride) {
|
|
|
|
sessionId = sessionIdOverride;
|
|
|
|
} else {
|
|
|
|
sessionId = proposal.downloadSessionId;
|
|
|
|
}
|
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);
|
2019-12-02 00:42:40 +01:00
|
|
|
const t: PurchaseRecord = {
|
2020-09-08 22:48:03 +02:00
|
|
|
abortStatus: AbortStatus.None,
|
2020-12-21 13:23:07 +01:00
|
|
|
download: d,
|
2019-12-10 12:22:29 +01:00
|
|
|
lastSessionId: sessionId,
|
2020-05-12 12:14:48 +02:00
|
|
|
payCoinSelection: coinSelection,
|
2021-05-12 13:34:49 +02:00
|
|
|
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,
|
2019-12-06 00:24:34 +01:00
|
|
|
lastPayError: undefined,
|
|
|
|
lastRefundStatusError: undefined,
|
2022-05-18 19:41:51 +02:00
|
|
|
payRetryInfo: RetryInfo.reset(),
|
|
|
|
refundStatusRetryInfo: RetryInfo.reset(),
|
2020-09-08 22:48:03 +02:00
|
|
|
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,
|
2019-12-10 12:22:29 +01:00
|
|
|
paymentSubmitPending: true,
|
2020-07-23 14:05:17 +02:00
|
|
|
refunds: {},
|
2020-08-19 17:25:38 +02:00
|
|
|
merchantPaySig: undefined,
|
2020-12-16 17:59:04 +01:00
|
|
|
noncePriv: proposal.noncePriv,
|
|
|
|
noncePub: proposal.noncePub,
|
2019-12-02 00:42:40 +01:00
|
|
|
};
|
|
|
|
|
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) {
|
2022-03-08 23:09:20 +01:00
|
|
|
p.proposalStatus = ProposalStatus.Accepted;
|
2021-06-10 16:32:37 +02:00
|
|
|
delete p.lastError;
|
2022-03-29 13:47:32 +02:00
|
|
|
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);
|
2021-06-22 18:43:11 +02:00
|
|
|
await applyCoinSpend(ws, tx, coinSelection, `proposal:${t.proposalId}`);
|
2021-06-09 15:14:17 +02:00
|
|
|
});
|
2019-12-02 00:42:40 +01:00
|
|
|
|
2019-12-05 19:38:19 +01:00
|
|
|
ws.notify({
|
|
|
|
type: NotificationType.ProposalAccepted,
|
|
|
|
proposalId: proposal.proposalId,
|
|
|
|
});
|
2019-12-02 00:42:40 +01:00
|
|
|
return t;
|
|
|
|
}
|
|
|
|
|
2022-03-08 23:09:20 +01:00
|
|
|
async function reportProposalError(
|
2019-12-05 19:38:19 +01:00
|
|
|
ws: InternalWalletState,
|
|
|
|
proposalId: string,
|
2022-03-22 21:16:38 +01:00
|
|
|
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) {
|
2022-03-08 23:09:20 +01:00
|
|
|
logger.error(
|
|
|
|
`Asked to report an error for a proposal (${proposalId}) that is not active (no retryInfo)`,
|
|
|
|
);
|
2022-05-18 19:41:51 +02:00
|
|
|
logger.reportBreak();
|
2021-06-09 15:14:17 +02:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
pr.lastError = err;
|
|
|
|
await tx.proposals.put(pr);
|
|
|
|
});
|
2022-03-08 23:09:20 +01:00
|
|
|
ws.notify({ type: NotificationType.ProposalOperationError, error: err });
|
|
|
|
}
|
|
|
|
|
2022-03-29 13:47:32 +02:00
|
|
|
async function setupProposalRetry(
|
2022-03-08 23:09:20 +01:00
|
|
|
ws: InternalWalletState,
|
|
|
|
proposalId: string,
|
2022-03-29 13:47:32 +02:00
|
|
|
options: {
|
|
|
|
reset: boolean;
|
|
|
|
},
|
2022-03-08 23:09:20 +01:00
|
|
|
): Promise<void> {
|
|
|
|
await ws.db
|
|
|
|
.mktx((x) => ({ proposals: x.proposals }))
|
|
|
|
.runReadWrite(async (tx) => {
|
|
|
|
const pr = await tx.proposals.get(proposalId);
|
|
|
|
if (!pr) {
|
|
|
|
return;
|
|
|
|
}
|
2022-03-29 13:47:32 +02:00
|
|
|
if (options.reset) {
|
2022-05-18 19:41:51 +02:00
|
|
|
pr.retryInfo = RetryInfo.reset();
|
2022-03-08 23:09:20 +01:00
|
|
|
} else {
|
2022-03-29 13:47:32 +02:00
|
|
|
pr.retryInfo = RetryInfo.increment(pr.retryInfo);
|
2022-03-08 23:09:20 +01:00
|
|
|
}
|
|
|
|
delete pr.lastError;
|
|
|
|
await tx.proposals.put(pr);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2022-03-29 13:47:32 +02:00
|
|
|
async function setupPurchasePayRetry(
|
2022-03-08 23:09:20 +01:00
|
|
|
ws: InternalWalletState,
|
|
|
|
proposalId: string,
|
2022-03-29 13:47:32 +02:00
|
|
|
options: {
|
|
|
|
reset: boolean;
|
|
|
|
},
|
2022-03-08 23:09:20 +01:00
|
|
|
): Promise<void> {
|
|
|
|
await ws.db
|
|
|
|
.mktx((x) => ({ purchases: x.purchases }))
|
|
|
|
.runReadWrite(async (tx) => {
|
|
|
|
const p = await tx.purchases.get(proposalId);
|
2022-03-29 13:47:32 +02:00
|
|
|
if (!p) {
|
2021-06-09 15:14:17 +02:00
|
|
|
return;
|
|
|
|
}
|
2022-03-29 13:47:32 +02:00
|
|
|
if (options.reset) {
|
2022-05-18 19:41:51 +02:00
|
|
|
p.payRetryInfo = RetryInfo.reset();
|
2022-03-29 13:47:32 +02:00
|
|
|
} else {
|
|
|
|
p.payRetryInfo = RetryInfo.increment(p.payRetryInfo);
|
|
|
|
}
|
|
|
|
delete p.lastPayError;
|
|
|
|
await tx.purchases.put(p);
|
2022-03-08 23:09:20 +01:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
async function reportPurchasePayError(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
proposalId: string,
|
2022-03-22 21:16:38 +01:00
|
|
|
err: TalerErrorDetail,
|
2022-03-08 23:09:20 +01:00
|
|
|
): 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);
|
|
|
|
});
|
2022-03-08 23:09:20 +01:00
|
|
|
ws.notify({ type: NotificationType.PayOperationError, error: err });
|
2019-12-06 00:24:34 +01:00
|
|
|
}
|
|
|
|
|
2019-12-03 00:52:15 +01:00
|
|
|
export async function processDownloadProposal(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
proposalId: string,
|
2022-03-29 13:47:32 +02:00
|
|
|
options: {
|
|
|
|
forceNow?: boolean;
|
|
|
|
} = {},
|
2019-12-05 19:38:19 +01:00
|
|
|
): Promise<void> {
|
2022-03-22 21:16:38 +01:00
|
|
|
const onOpErr = (err: TalerErrorDetail): Promise<void> =>
|
2022-03-08 23:09:20 +01:00
|
|
|
reportProposalError(ws, proposalId, err);
|
2019-12-05 19:38:19 +01:00
|
|
|
await guardOperationException(
|
2022-03-29 13:47:32 +02:00
|
|
|
() => processDownloadProposalImpl(ws, proposalId, options),
|
2019-12-05 19:38:19 +01:00
|
|
|
onOpErr,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2020-11-03 17:39:30 +01:00
|
|
|
async function failProposalPermanently(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
proposalId: string,
|
2022-03-22 21:16:38 +01:00
|
|
|
err: TalerErrorDetail,
|
2020-11-03 17:39:30 +01:00
|
|
|
): 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;
|
2022-03-08 23:09:20 +01:00
|
|
|
p.proposalStatus = ProposalStatus.PermanentlyFailed;
|
2021-06-09 15:14:17 +02:00
|
|
|
await tx.proposals.put(p);
|
|
|
|
});
|
2020-11-03 17:39:30 +01:00
|
|
|
}
|
|
|
|
|
2020-08-20 12:57:20 +02:00
|
|
|
function getProposalRequestTimeout(proposal: ProposalRecord): Duration {
|
2022-05-19 10:36:01 +02:00
|
|
|
return Duration.clamp({
|
2022-05-19 11:01:07 +02:00
|
|
|
lower: Duration.fromSpec({ seconds: 1 }),
|
|
|
|
upper: Duration.fromSpec({ seconds: 60 }),
|
|
|
|
value: RetryInfo.getDuration(proposal.retryInfo),
|
2022-05-19 10:36:01 +02:00
|
|
|
});
|
2020-08-20 12:57:20 +02:00
|
|
|
}
|
|
|
|
|
2020-09-07 12:24:22 +02:00
|
|
|
function getPayRequestTimeout(purchase: PurchaseRecord): Duration {
|
2020-09-08 22:48:03 +02:00
|
|
|
return durationMul(
|
2021-02-05 12:10:56 +01:00
|
|
|
{ d_ms: 15000 },
|
|
|
|
1 + purchase.payCoinSelection.coinPubs.length / 5,
|
2020-09-08 22:48:03 +02:00
|
|
|
);
|
2020-08-20 12:57:20 +02:00
|
|
|
}
|
|
|
|
|
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,
|
2022-04-19 17:12:43 +02:00
|
|
|
minimumAge: parsedContractTerms.minimum_age,
|
2022-08-08 18:53:04 +02:00
|
|
|
deliveryDate: parsedContractTerms.delivery_date,
|
|
|
|
deliveryLocation: parsedContractTerms.delivery_location,
|
2021-01-18 23:35:41 +01:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2019-12-05 19:38:19 +01:00
|
|
|
async function processDownloadProposalImpl(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
proposalId: string,
|
2022-03-29 13:47:32 +02:00
|
|
|
options: {
|
|
|
|
forceNow?: boolean;
|
|
|
|
} = {},
|
2019-12-03 00:52:15 +01:00
|
|
|
): Promise<void> {
|
2022-03-29 13:47:32 +02:00
|
|
|
const forceNow = options.forceNow ?? false;
|
2022-05-18 20:57:10 +02:00
|
|
|
await setupProposalRetry(ws, proposalId, { reset: forceNow });
|
|
|
|
|
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);
|
|
|
|
});
|
2022-03-08 23:09:20 +01:00
|
|
|
|
2019-12-03 00:52:15 +01:00
|
|
|
if (!proposal) {
|
|
|
|
return;
|
|
|
|
}
|
2022-03-08 23:09:20 +01:00
|
|
|
|
|
|
|
if (proposal.proposalStatus != ProposalStatus.Downloading) {
|
2019-12-03 00:52:15 +01:00
|
|
|
return;
|
|
|
|
}
|
2019-12-07 22:02:11 +01:00
|
|
|
|
2020-07-20 14:16:49 +02:00
|
|
|
const orderClaimUrl = new URL(
|
|
|
|
`orders/${proposal.orderId}/claim`,
|
|
|
|
proposal.merchantBaseUrl,
|
|
|
|
).href;
|
|
|
|
logger.trace("downloading contract from '" + orderClaimUrl + "'");
|
|
|
|
|
2020-07-30 13:58:09 +02:00
|
|
|
const requestBody: {
|
2020-08-03 09:30:48 +02:00
|
|
|
nonce: string;
|
2020-07-30 13:58:09 +02:00
|
|
|
token?: string;
|
|
|
|
} = {
|
2020-07-22 10:52:03 +02:00
|
|
|
nonce: proposal.noncePub,
|
|
|
|
};
|
2020-07-30 13:58:09 +02:00
|
|
|
if (proposal.claimToken) {
|
|
|
|
requestBody.token = proposal.claimToken;
|
|
|
|
}
|
2020-07-22 10:52:03 +02:00
|
|
|
|
2020-08-24 08:22:12 +02:00
|
|
|
const httpResponse = await ws.http.postJson(orderClaimUrl, requestBody, {
|
2020-08-20 12:57:20 +02:00
|
|
|
timeout: getProposalRequestTimeout(proposal),
|
|
|
|
});
|
2020-08-24 16:09:09 +02:00
|
|
|
const r = await readSuccessResponseJsonOrErrorCode(
|
|
|
|
httpResponse,
|
|
|
|
codecForProposal(),
|
|
|
|
);
|
2020-08-24 08:22:12 +02:00
|
|
|
if (r.isError) {
|
|
|
|
switch (r.talerErrorResponse.code) {
|
2020-11-08 01:20:50 +01:00
|
|
|
case TalerErrorCode.MERCHANT_POST_ORDERS_ID_CLAIM_ALREADY_CLAIMED:
|
2022-03-22 21:16:38 +01:00
|
|
|
throw TalerError.fromDetail(
|
2020-08-24 08:22:12 +02:00
|
|
|
TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED,
|
|
|
|
{
|
|
|
|
orderId: proposal.orderId,
|
|
|
|
claimUrl: orderClaimUrl,
|
2020-08-24 16:09:09 +02:00
|
|
|
},
|
2022-03-22 21:16:38 +01:00
|
|
|
"order already claimed (likely by other wallet)",
|
2020-08-24 16:09:09 +02:00
|
|
|
);
|
2020-08-24 08:22:12 +02:00
|
|
|
default:
|
|
|
|
throwUnexpectedRequestError(httpResponse, r.talerErrorResponse);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
const proposalResp = r.response;
|
2019-12-09 13:29:11 +01:00
|
|
|
|
2020-07-20 14:16:49 +02:00
|
|
|
// The proposalResp contains the contract terms as raw JSON,
|
|
|
|
// as the coded to parse them doesn't necessarily round-trip.
|
|
|
|
// We need this raw JSON to compute the contract terms hash.
|
2019-12-03 00:52:15 +01:00
|
|
|
|
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) {
|
2021-06-22 13:52:28 +02:00
|
|
|
logger.trace(
|
|
|
|
`malformed contract terms: ${j2s(proposalResp.contract_terms)}`,
|
|
|
|
);
|
2022-03-22 21:16:38 +01:00
|
|
|
const err = makeErrorDetail(
|
2021-04-14 14:36:46 +02:00
|
|
|
TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED,
|
|
|
|
{},
|
2022-03-22 21:16:38 +01:00
|
|
|
"validation for well-formedness failed",
|
2021-04-14 14:36:46 +02:00
|
|
|
);
|
|
|
|
await failProposalPermanently(ws, proposalId, err);
|
2022-03-22 21:16:38 +01:00
|
|
|
throw makePendingOperationFailedError(
|
|
|
|
err,
|
|
|
|
TransactionType.Payment,
|
|
|
|
proposalId,
|
|
|
|
);
|
2021-04-14 14:36:46 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
const contractTermsHash = ContractTermsUtil.hashContractTerms(
|
2019-12-19 20:42:49 +01:00
|
|
|
proposalResp.contract_terms,
|
|
|
|
);
|
2020-11-03 17:39:30 +01:00
|
|
|
|
2021-10-18 21:48:22 +02:00
|
|
|
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) {
|
2022-03-22 21:16:38 +01:00
|
|
|
const err = makeErrorDetail(
|
2021-04-14 14:36:46 +02:00
|
|
|
TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED,
|
|
|
|
{},
|
2022-03-22 21:16:38 +01:00
|
|
|
`schema validation failed: ${e}`,
|
2021-04-14 14:36:46 +02:00
|
|
|
);
|
|
|
|
await failProposalPermanently(ws, proposalId, err);
|
2022-03-22 21:16:38 +01:00
|
|
|
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({
|
2020-11-03 17:39:30 +01:00
|
|
|
contractTermsHash,
|
2022-03-23 21:24:23 +01:00
|
|
|
merchantPub: parsedContractTerms.merchant_pub,
|
|
|
|
sig: proposalResp.sig,
|
|
|
|
});
|
2020-11-03 17:39:30 +01:00
|
|
|
|
|
|
|
if (!sigValid) {
|
2022-03-22 21:16:38 +01:00
|
|
|
const err = makeErrorDetail(
|
2020-11-03 17:39:30 +01:00
|
|
|
TalerErrorCode.WALLET_CONTRACT_TERMS_SIGNATURE_INVALID,
|
|
|
|
{
|
|
|
|
merchantPub: parsedContractTerms.merchant_pub,
|
|
|
|
orderId: parsedContractTerms.order_id,
|
|
|
|
},
|
2022-03-22 21:16:38 +01:00
|
|
|
"merchant's signature on contract terms is invalid",
|
2020-11-03 17:39:30 +01:00
|
|
|
);
|
|
|
|
await failProposalPermanently(ws, proposalId, err);
|
2022-03-22 21:16:38 +01:00
|
|
|
throw makePendingOperationFailedError(
|
|
|
|
err,
|
|
|
|
TransactionType.Payment,
|
|
|
|
proposalId,
|
|
|
|
);
|
2020-11-03 17:39:30 +01:00
|
|
|
}
|
|
|
|
|
2019-12-19 20:42:49 +01:00
|
|
|
const fulfillmentUrl = parsedContractTerms.fulfillment_url;
|
2019-12-03 00:52:15 +01:00
|
|
|
|
2020-11-03 16:46:43 +01:00
|
|
|
const baseUrlForDownload = proposal.merchantBaseUrl;
|
|
|
|
const baseUrlFromContractTerms = parsedContractTerms.merchant_base_url;
|
|
|
|
|
|
|
|
if (baseUrlForDownload !== baseUrlFromContractTerms) {
|
2022-03-22 21:16:38 +01:00
|
|
|
const err = makeErrorDetail(
|
2020-11-03 16:46:43 +01:00
|
|
|
TalerErrorCode.WALLET_CONTRACT_TERMS_BASE_URL_MISMATCH,
|
|
|
|
{
|
|
|
|
baseUrlForDownload,
|
|
|
|
baseUrlFromContractTerms,
|
|
|
|
},
|
2022-03-22 21:16:38 +01:00
|
|
|
"merchant base URL mismatch",
|
2020-11-03 16:46:43 +01:00
|
|
|
);
|
2020-11-03 17:39:30 +01:00
|
|
|
await failProposalPermanently(ws, proposalId, err);
|
2022-03-22 21:16:38 +01:00
|
|
|
throw makePendingOperationFailedError(
|
|
|
|
err,
|
|
|
|
TransactionType.Payment,
|
|
|
|
proposalId,
|
|
|
|
);
|
2020-11-03 16:46:43 +01:00
|
|
|
}
|
|
|
|
|
2021-01-18 23:35:41 +01:00
|
|
|
const contractData = extractContractData(
|
|
|
|
parsedContractTerms,
|
|
|
|
contractTermsHash,
|
|
|
|
proposalResp.sig,
|
|
|
|
);
|
|
|
|
|
2022-04-19 17:12:43 +02:00
|
|
|
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;
|
|
|
|
}
|
2022-03-08 23:09:20 +01:00
|
|
|
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 (
|
2020-08-24 16:09:09 +02:00
|
|
|
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) {
|
2020-08-14 12:23:50 +02:00
|
|
|
logger.warn("repurchase detected");
|
2022-03-08 23:09:20 +01:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
}
|
2022-03-08 23:09:20 +01:00
|
|
|
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
|
|
|
}
|
|
|
|
|
2019-12-02 00:42:40 +01:00
|
|
|
/**
|
|
|
|
* Download a proposal and store it in the database.
|
|
|
|
* Returns an id for it to retrieve it later.
|
|
|
|
*
|
|
|
|
* @param sessionId Current session ID, if the proposal is being
|
|
|
|
* downloaded in the context of a session ID.
|
|
|
|
*/
|
2019-12-03 00:52:15 +01:00
|
|
|
async function startDownloadProposal(
|
2019-12-02 00:42:40 +01:00
|
|
|
ws: InternalWalletState,
|
2019-12-06 12:47:28 +01:00
|
|
|
merchantBaseUrl: string,
|
|
|
|
orderId: string,
|
2019-12-10 12:22:29 +01:00
|
|
|
sessionId: string | undefined,
|
2020-07-30 13:58:09 +02:00
|
|
|
claimToken: string | undefined,
|
2021-09-17 20:48:33 +02:00
|
|
|
noncePriv: string | undefined,
|
2019-12-02 00:42:40 +01:00
|
|
|
): 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
|
|
|
|
2022-03-22 21:16:38 +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);
|
2019-12-02 00:42:40 +01:00
|
|
|
return oldProposal.proposalId;
|
|
|
|
}
|
|
|
|
|
2022-03-23 21:24:23 +01:00
|
|
|
let noncePair: EddsaKeypair;
|
|
|
|
if (noncePriv) {
|
2022-03-24 01:59:08 +01:00
|
|
|
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;
|
2019-12-02 00:42:40 +01:00
|
|
|
const proposalId = encodeCrock(getRandomBytes(32));
|
|
|
|
|
|
|
|
const proposalRecord: ProposalRecord = {
|
2019-12-03 00:52:15 +01:00
|
|
|
download: undefined,
|
2019-12-02 00:42:40 +01:00
|
|
|
noncePriv: priv,
|
2019-12-03 00:52:15 +01:00
|
|
|
noncePub: pub,
|
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,
|
2019-12-02 00:42:40 +01:00
|
|
|
proposalId: proposalId,
|
2022-03-08 23:09:20 +01:00
|
|
|
proposalStatus: ProposalStatus.Downloading,
|
2019-12-03 00:52:15 +01:00
|
|
|
repurchaseProposalId: undefined,
|
2022-05-18 19:41:51 +02:00
|
|
|
retryInfo: RetryInfo.reset(),
|
2019-12-05 19:38:19 +01:00
|
|
|
lastError: undefined,
|
2019-12-10 12:22:29 +01:00
|
|
|
downloadSessionId: sessionId,
|
2019-12-02 00:42:40 +01:00
|
|
|
};
|
|
|
|
|
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-10 12:22:29 +01:00
|
|
|
|
2019-12-03 00:52:15 +01:00
|
|
|
await processDownloadProposal(ws, proposalId);
|
2019-12-02 00:42:40 +01:00
|
|
|
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;
|
2022-05-18 19:41:51 +02:00
|
|
|
purchase.payRetryInfo = RetryInfo.reset();
|
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;
|
2022-05-18 19:41:51 +02:00
|
|
|
purchase.refundStatusRetryInfo = RetryInfo.reset();
|
2022-05-14 23:09:33 +02:00
|
|
|
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;
|
2022-05-18 19:41:51 +02:00
|
|
|
purchase.payRetryInfo = RetryInfo.reset();
|
2021-06-09 15:14:17 +02:00
|
|
|
purchase.lastSessionId = sessionId;
|
|
|
|
await tx.purchases.put(purchase);
|
|
|
|
});
|
2020-08-19 17:25:38 +02:00
|
|
|
}
|
|
|
|
|
2021-03-11 13:08:41 +01: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
|
2021-04-07 19:29:51 +02:00
|
|
|
* (2) adjusting the remaining coin value and refreshing it
|
2021-03-15 13:43:53 +01:00
|
|
|
* (3) re-do coin selection with the bad coin removed
|
2021-03-11 13:08:41 +01:00
|
|
|
*/
|
|
|
|
async function handleInsufficientFunds(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
proposalId: string,
|
2022-03-22 21:16:38 +01:00
|
|
|
err: TalerErrorDetail,
|
2021-03-11 13:08:41 +01:00
|
|
|
): Promise<void> {
|
2021-04-07 19:29:51 +02:00
|
|
|
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);
|
|
|
|
});
|
2021-03-15 13:43:53 +01:00
|
|
|
if (!proposal) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2021-04-07 19:29:51 +02:00
|
|
|
const brokenCoinPub = (err as any).coin_pub;
|
|
|
|
|
|
|
|
const exchangeReply = (err as any).exchange_reply;
|
|
|
|
if (
|
2022-03-08 23:09:20 +01:00
|
|
|
exchangeReply.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS
|
2021-04-07 19:29:51 +02:00
|
|
|
) {
|
|
|
|
// FIXME: set as failed
|
2022-03-08 23:09:20 +01:00
|
|
|
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})`);
|
2021-04-07 19:29:51 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
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,
|
|
|
|
});
|
|
|
|
}
|
2021-04-07 19:29:51 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
const res = selectPayCoins({
|
|
|
|
candidates,
|
|
|
|
contractTermsAmount: contractData.amount,
|
|
|
|
depositFeeLimit: contractData.maxDepositFee,
|
|
|
|
wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
|
|
|
|
wireFeeLimit: contractData.maxWireFee,
|
|
|
|
prevPayCoins,
|
2022-04-29 21:05:17 +02:00
|
|
|
requiredMinimumAge: contractData.minimumAge,
|
2021-04-07 19:29:51 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
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);
|
2021-04-07 19:29:51 +02:00
|
|
|
if (!p) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
p.payCoinSelection = res;
|
2021-06-22 18:43:11 +02:00
|
|
|
p.payCoinSelectionUid = encodeCrock(getRandomBytes(32));
|
2021-04-07 19:29:51 +02:00
|
|
|
p.coinDepositPermissions = undefined;
|
2021-06-09 15:14:17 +02:00
|
|
|
await tx.purchases.put(p);
|
2021-06-22 18:43:11 +02:00
|
|
|
await applyCoinSpend(ws, tx, res, `proposal:${p.proposalId}`);
|
2021-06-09 15:14:17 +02:00
|
|
|
});
|
2021-03-11 13:08:41 +01:00
|
|
|
}
|
|
|
|
|
2021-06-25 13:27:06 +02:00
|
|
|
async function unblockBackup(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
proposalId: string,
|
|
|
|
): Promise<void> {
|
|
|
|
await ws.db
|
|
|
|
.mktx((x) => ({ backupProviders: x.backupProviders }))
|
|
|
|
.runReadWrite(async (tx) => {
|
2022-01-16 21:47:43 +01:00
|
|
|
await tx.backupProviders.indexes.byPaymentProposalId
|
2021-06-25 13:27:06 +02:00
|
|
|
.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(),
|
2021-06-25 13:27:06 +02:00
|
|
|
};
|
|
|
|
}
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2021-03-10 17:11:59 +01:00
|
|
|
export async function checkPaymentByProposalId(
|
2019-12-02 00:42:40 +01:00
|
|
|
ws: InternalWalletState,
|
2021-03-10 17:11:59 +01:00
|
|
|
proposalId: string,
|
|
|
|
sessionId?: string,
|
2019-12-02 00:42:40 +01:00
|
|
|
): 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}`);
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
2022-03-08 23:09:20 +01:00
|
|
|
if (proposal.proposalStatus === ProposalStatus.Repurchase) {
|
2019-12-03 00:52:15 +01:00
|
|
|
const existingProposalId = proposal.repurchaseProposalId;
|
|
|
|
if (!existingProposalId) {
|
|
|
|
throw Error("invalid proposal state");
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
2020-08-14 12:23:50 +02:00
|
|
|
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) {
|
2020-08-14 12:23:50 +02:00
|
|
|
logger.error("bad proposal", proposal);
|
2019-12-03 00:52:15 +01:00
|
|
|
throw Error("proposal is in invalid state");
|
|
|
|
}
|
2019-12-19 20:42:49 +01:00
|
|
|
const contractData = d.contractData;
|
|
|
|
const merchantSig = d.contractData.merchantSig;
|
|
|
|
if (!merchantSig) {
|
2019-12-03 00:52:15 +01:00
|
|
|
throw Error("BUG: proposal is in invalid state");
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
|
|
|
|
2019-12-06 00:56:31 +01:00
|
|
|
proposalId = proposal.proposalId;
|
2019-12-03 00:52:15 +01:00
|
|
|
|
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);
|
|
|
|
});
|
2019-12-02 00:42:40 +01:00
|
|
|
|
|
|
|
if (!purchase) {
|
2019-12-19 20:42:49 +01:00
|
|
|
// If not already paid, check if we could pay for it.
|
2021-03-15 13:43:53 +01:00
|
|
|
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: [],
|
2022-04-29 21:05:17 +02:00
|
|
|
requiredMinimumAge: contractData.minimumAge,
|
2021-03-15 13:43:53 +01:00
|
|
|
});
|
2019-12-02 00:42:40 +01:00
|
|
|
|
|
|
|
if (!res) {
|
2020-07-30 13:58:09 +02:00
|
|
|
logger.info("not confirming payment, insufficient coins");
|
2019-12-02 00:42:40 +01:00
|
|
|
return {
|
2020-07-28 11:48:01 +02:00
|
|
|
status: PreparePayResultType.InsufficientBalance,
|
2020-12-21 13:23:07 +01:00
|
|
|
contractTerms: d.contractTermsRaw,
|
2019-12-02 00:42:40 +01:00
|
|
|
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),
|
2019-12-02 00:42:40 +01:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
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
|
|
|
|
2019-12-02 00:42:40 +01:00
|
|
|
return {
|
2020-07-28 11:48:01 +02:00
|
|
|
status: PreparePayResultType.PaymentPossible,
|
2020-12-21 13:23:07 +01:00
|
|
|
contractTerms: d.contractTermsRaw,
|
2019-12-02 00:42:40 +01:00
|
|
|
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,
|
2019-12-02 00:42:40 +01:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2021-03-10 17:11:59 +01:00
|
|
|
if (purchase.lastSessionId !== sessionId) {
|
2020-07-29 19:40:41 +02:00
|
|
|
logger.trace(
|
2019-12-19 20:42:49 +01:00
|
|
|
"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;
|
2022-03-08 23:09:20 +01:00
|
|
|
p.paymentSubmitPending = true;
|
2021-06-09 15:14:17 +02:00
|
|
|
await tx.purchases.put(p);
|
|
|
|
});
|
2022-03-29 13:47:32 +02:00
|
|
|
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");
|
|
|
|
}
|
2020-07-28 11:48:01 +02:00
|
|
|
return {
|
|
|
|
status: PreparePayResultType.AlreadyConfirmed,
|
2020-12-21 13:23:07 +01:00
|
|
|
contractTerms: purchase.download.contractTermsRaw,
|
|
|
|
contractTermsHash: purchase.download.contractData.contractTermsHash,
|
2020-07-28 11:48:01 +02:00
|
|
|
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),
|
2020-09-03 14:03:11 +02:00
|
|
|
proposalId,
|
2020-07-28 11:48:01 +02:00
|
|
|
};
|
|
|
|
} 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,
|
2020-07-28 11:48:01 +02:00
|
|
|
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),
|
2020-09-03 14:03:11 +02:00
|
|
|
proposalId,
|
2020-07-30 13:58:09 +02:00
|
|
|
};
|
2020-08-12 13:02:07 +02:00
|
|
|
} else {
|
|
|
|
const paid = !purchase.paymentSubmitPending;
|
2020-07-28 11:48:01 +02:00
|
|
|
return {
|
|
|
|
status: PreparePayResultType.AlreadyConfirmed,
|
2020-12-21 13:23:07 +01:00
|
|
|
contractTerms: purchase.download.contractTermsRaw,
|
|
|
|
contractTermsHash: purchase.download.contractData.contractTermsHash,
|
2020-08-12 13:02:07 +02:00
|
|
|
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 } : {}),
|
2020-09-03 14:03:11 +02:00
|
|
|
proposalId,
|
2020-07-28 11:48:01 +02:00
|
|
|
};
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-08-08 18:53:04 +02:00
|
|
|
export async function getContractTermsDetails(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
proposalId: string,
|
|
|
|
): Promise<WalletContractData> {
|
|
|
|
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`);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!proposal.download || !proposal.download.contractData) {
|
|
|
|
throw Error("proposal is in invalid state");
|
|
|
|
}
|
|
|
|
|
|
|
|
return proposal.download.contractData
|
|
|
|
}
|
|
|
|
|
2021-03-10 17:11:59 +01: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.
|
|
|
|
*/
|
|
|
|
export async function preparePayForUri(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
talerPayUri: string,
|
|
|
|
): Promise<PreparePayResult> {
|
|
|
|
const uriResult = parsePayUri(talerPayUri);
|
|
|
|
|
|
|
|
if (!uriResult) {
|
2022-03-22 21:16:38 +01:00
|
|
|
throw TalerError.fromDetail(
|
2021-03-10 17:11:59 +01:00
|
|
|
TalerErrorCode.WALLET_INVALID_TALER_PAY_URI,
|
|
|
|
{
|
|
|
|
talerPayUri,
|
|
|
|
},
|
2022-03-22 21:16:38 +01:00
|
|
|
`invalid taler://pay URI (${talerPayUri})`,
|
2021-03-10 17:11:59 +01:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
let proposalId = await startDownloadProposal(
|
|
|
|
ws,
|
|
|
|
uriResult.merchantBaseUrl,
|
|
|
|
uriResult.orderId,
|
|
|
|
uriResult.sessionId,
|
|
|
|
uriResult.claimToken,
|
2021-09-17 20:48:33 +02:00
|
|
|
uriResult.noncePriv,
|
2021-03-10 17:11:59 +01:00
|
|
|
);
|
|
|
|
|
|
|
|
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;
|
2022-02-21 12:40:51 +01:00
|
|
|
wireInfoHash = contractData.wireInfoHash;
|
2022-04-19 17:12:43 +02:00
|
|
|
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,
|
2022-03-10 16:30:24 +01:00
|
|
|
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,
|
2022-04-19 17:12:43 +02:00
|
|
|
ageCommitmentProof: coin.ageCommitmentProof,
|
|
|
|
requiredMinimumAge: contractData.minimumAge,
|
2021-01-04 13:30:38 +01:00
|
|
|
});
|
|
|
|
depositPermissions.push(dp);
|
|
|
|
}
|
|
|
|
return depositPermissions;
|
|
|
|
}
|
|
|
|
|
2019-12-02 00:42:40 +01:00
|
|
|
/**
|
|
|
|
* Add a contract to the wallet and sign coins, and send them.
|
|
|
|
*/
|
|
|
|
export async function confirmPay(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
proposalId: string,
|
2021-01-07 19:50:53 +01:00
|
|
|
sessionIdOverride?: string,
|
2022-06-10 13:03:47 +02:00
|
|
|
forcedCoinSel?: ForcedCoinSel,
|
2019-12-02 00:42:40 +01:00
|
|
|
): 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);
|
|
|
|
});
|
2019-12-02 00:42:40 +01:00
|
|
|
|
|
|
|
if (!proposal) {
|
|
|
|
throw Error(`proposal with id ${proposalId} not found`);
|
|
|
|
}
|
|
|
|
|
2019-12-03 00:52:15 +01:00
|
|
|
const d = proposal.download;
|
|
|
|
if (!d) {
|
|
|
|
throw Error("proposal is in invalid state");
|
|
|
|
}
|
|
|
|
|
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;
|
|
|
|
});
|
2019-12-02 00:42:40 +01:00
|
|
|
|
2021-06-09 15:14:17 +02:00
|
|
|
if (existingPurchase) {
|
2019-12-10 12:22:29 +01:00
|
|
|
logger.trace("confirmPay: submitting payment for existing purchase");
|
2022-03-29 13:47:32 +02:00
|
|
|
return await processPurchasePay(ws, proposalId, { forceNow: true });
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
|
|
|
|
2019-12-10 12:22:29 +01:00
|
|
|
logger.trace("confirmPay: purchase record does not exist yet");
|
|
|
|
|
2021-03-15 13:43:53 +01:00
|
|
|
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,
|
|
|
|
});
|
|
|
|
|
2022-06-10 13:03:47 +02:00
|
|
|
let res: PayCoinSelection | undefined = undefined;
|
|
|
|
|
|
|
|
if (forcedCoinSel) {
|
|
|
|
res = selectForcedPayCoins(forcedCoinSel, {
|
|
|
|
candidates,
|
|
|
|
contractTermsAmount: contractData.amount,
|
|
|
|
depositFeeLimit: contractData.maxDepositFee,
|
|
|
|
wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
|
|
|
|
wireFeeLimit: contractData.maxWireFee,
|
|
|
|
requiredMinimumAge: contractData.minimumAge,
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
res = selectPayCoins({
|
|
|
|
candidates,
|
|
|
|
contractTermsAmount: contractData.amount,
|
|
|
|
depositFeeLimit: contractData.maxDepositFee,
|
|
|
|
wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
|
|
|
|
wireFeeLimit: contractData.maxWireFee,
|
|
|
|
prevPayCoins: [],
|
|
|
|
requiredMinimumAge: contractData.minimumAge,
|
|
|
|
});
|
|
|
|
}
|
2019-12-02 00:42:40 +01:00
|
|
|
|
|
|
|
logger.trace("coin selection result", res);
|
|
|
|
|
|
|
|
if (!res) {
|
|
|
|
// Should not happen, since checkPay should be called first
|
2021-03-15 13:43:53 +01:00
|
|
|
// 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");
|
2019-12-02 00:42:40 +01:00
|
|
|
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,
|
2019-12-19 20:42:49 +01:00
|
|
|
sessionIdOverride,
|
2019-12-15 21:40:06 +01:00
|
|
|
);
|
2019-12-02 00:42:40 +01:00
|
|
|
|
2022-03-29 13:47:32 +02:00
|
|
|
return await processPurchasePay(ws, proposalId, { forceNow: true });
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
2019-12-03 14:40:05 +01:00
|
|
|
|
2019-12-06 00:24:34 +01:00
|
|
|
export async function processPurchasePay(
|
2019-12-05 19:38:19 +01:00
|
|
|
ws: InternalWalletState,
|
|
|
|
proposalId: string,
|
2022-03-29 13:47:32 +02:00
|
|
|
options: {
|
|
|
|
forceNow?: boolean;
|
|
|
|
} = {},
|
2022-03-08 23:09:20 +01:00
|
|
|
): Promise<ConfirmPayResult> {
|
2022-03-22 21:16:38 +01:00
|
|
|
const onOpErr = (e: TalerErrorDetail): Promise<void> =>
|
2022-03-08 23:09:20 +01:00
|
|
|
reportPurchasePayError(ws, proposalId, e);
|
|
|
|
return await guardOperationException(
|
2022-03-29 13:47:32 +02:00
|
|
|
() => processPurchasePayImpl(ws, proposalId, options),
|
2019-12-05 19:38:19 +01:00
|
|
|
onOpErr,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2019-12-06 00:24:34 +01:00
|
|
|
async function processPurchasePayImpl(
|
2019-12-05 19:38:19 +01:00
|
|
|
ws: InternalWalletState,
|
|
|
|
proposalId: string,
|
2022-03-29 13:47:32 +02:00
|
|
|
options: {
|
|
|
|
forceNow?: boolean;
|
|
|
|
} = {},
|
2022-03-08 23:09:20 +01:00
|
|
|
): Promise<ConfirmPayResult> {
|
2022-03-29 13:47:32 +02:00
|
|
|
const forceNow = options.forceNow ?? false;
|
2022-05-18 21:39:36 +02:00
|
|
|
await setupPurchasePayRetry(ws, proposalId, { reset: forceNow });
|
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) {
|
2022-03-08 23:09:20 +01:00
|
|
|
return {
|
|
|
|
type: ConfirmPayResultType.Pending,
|
|
|
|
lastError: {
|
|
|
|
// FIXME: allocate more specific error code
|
|
|
|
code: TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
|
2022-03-22 21:16:38 +01:00
|
|
|
hint: `trying to pay for purchase that is not in the database`,
|
|
|
|
proposalId: proposalId,
|
2022-03-08 23:09:20 +01:00
|
|
|
},
|
|
|
|
};
|
2019-12-05 19:38:19 +01:00
|
|
|
}
|
2019-12-10 12:22:29 +01:00
|
|
|
if (!purchase.paymentSubmitPending) {
|
2022-03-08 23:09:20 +01:00
|
|
|
return {
|
|
|
|
type: ConfirmPayResultType.Pending,
|
|
|
|
lastError: purchase.lastPayError,
|
|
|
|
};
|
|
|
|
}
|
2019-12-10 12:22:29 +01:00
|
|
|
logger.trace(`processing purchase pay ${proposalId}`);
|
2022-03-08 23:09:20 +01:00
|
|
|
|
|
|
|
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");
|
2022-03-22 21:16:38 +01:00
|
|
|
const err = makeErrorDetail(
|
2022-03-08 23:09:20 +01:00
|
|
|
TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
|
|
|
|
getHttpResponseErrorDetails(resp),
|
2022-03-22 21:16:38 +01:00
|
|
|
"/pay failed",
|
2022-03-08 23:09:20 +01:00
|
|
|
);
|
|
|
|
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);
|
|
|
|
});
|
2022-03-22 21:16:38 +01:00
|
|
|
throw makePendingOperationFailedError(
|
|
|
|
errDetails,
|
|
|
|
TransactionType.Payment,
|
|
|
|
proposalId,
|
|
|
|
);
|
2022-03-08 23:09:20 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
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,
|
2022-03-08 23:09:20 +01:00
|
|
|
merchantPub,
|
2022-03-23 21:24:23 +01:00
|
|
|
sig: merchantResp.sig,
|
|
|
|
});
|
2022-03-08 23:09:20 +01:00
|
|
|
|
|
|
|
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
|
|
|
|
) {
|
2022-03-22 21:16:38 +01:00
|
|
|
const err = makeErrorDetail(
|
2022-03-08 23:09:20 +01:00
|
|
|
TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
|
|
|
|
getHttpResponseErrorDetails(resp),
|
2022-03-22 21:16:38 +01:00
|
|
|
"/paid failed",
|
2022-03-08 23:09:20 +01:00
|
|
|
);
|
|
|
|
return {
|
|
|
|
type: ConfirmPayResultType.Pending,
|
|
|
|
lastError: err,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
if (resp.status !== 204) {
|
2022-03-22 21:16:38 +01:00
|
|
|
throw TalerError.fromDetail(
|
2022-03-08 23:09:20 +01:00
|
|
|
TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
|
|
|
|
getHttpResponseErrorDetails(resp),
|
2022-03-22 21:16:38 +01:00
|
|
|
"/paid failed",
|
2022-03-08 23:09:20 +01:00
|
|
|
);
|
|
|
|
}
|
|
|
|
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-06 00:24:34 +01:00
|
|
|
}
|
2019-12-20 01:25:22 +01:00
|
|
|
|
2019-12-25 19:11:20 +01:00
|
|
|
export async function refuseProposal(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
proposalId: string,
|
2020-04-06 20:02:01 +02:00
|
|
|
): Promise<void> {
|
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;
|
|
|
|
}
|
2022-03-08 23:09:20 +01:00
|
|
|
if (proposal.proposalStatus !== ProposalStatus.Proposed) {
|
2019-12-25 19:11:20 +01:00
|
|
|
return false;
|
|
|
|
}
|
2022-03-08 23:09:20 +01:00
|
|
|
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
|
|
|
}
|