summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-core/src/operations/pay-merchant.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/taler-wallet-core/src/operations/pay-merchant.ts')
-rw-r--r--packages/taler-wallet-core/src/operations/pay-merchant.ts2758
1 files changed, 2758 insertions, 0 deletions
diff --git a/packages/taler-wallet-core/src/operations/pay-merchant.ts b/packages/taler-wallet-core/src/operations/pay-merchant.ts
new file mode 100644
index 000000000..97901c71e
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/pay-merchant.ts
@@ -0,0 +1,2758 @@
+/*
+ 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.
+ */
+import { GlobalIDB } from "@gnu-taler/idb-bridge";
+import {
+ AbortingCoin,
+ AbortRequest,
+ AbsoluteTime,
+ AgeRestriction,
+ AmountJson,
+ Amounts,
+ ApplyRefundResponse,
+ codecForAbortResponse,
+ codecForContractTerms,
+ codecForMerchantOrderRefundPickupResponse,
+ codecForMerchantOrderStatusPaid,
+ codecForMerchantPayResponse,
+ codecForProposal,
+ CoinDepositPermission,
+ CoinPublicKey,
+ ConfirmPayResult,
+ ConfirmPayResultType,
+ ContractTerms,
+ ContractTermsUtil,
+ DenominationInfo,
+ Duration,
+ encodeCrock,
+ ForcedCoinSel,
+ getRandomBytes,
+ HttpStatusCode,
+ j2s,
+ Logger,
+ MerchantCoinRefundFailureStatus,
+ MerchantCoinRefundStatus,
+ MerchantCoinRefundSuccessStatus,
+ NotificationType,
+ parsePaytoUri,
+ parsePayUri,
+ parseRefundUri,
+ PayCoinSelection,
+ PreparePayResult,
+ PreparePayResultType,
+ PrepareRefundResult,
+ RefreshReason,
+ strcmp,
+ TalerErrorCode,
+ TalerErrorDetail,
+ TalerProtocolTimestamp,
+ TransactionType,
+ URL,
+} from "@gnu-taler/taler-util";
+import { EddsaKeypair } from "../crypto/cryptoImplementation.js";
+import {
+ AllowedAuditorInfo,
+ AllowedExchangeInfo,
+ BackupProviderStateTag,
+ CoinRecord,
+ CoinStatus,
+ DenominationRecord,
+ ProposalDownload,
+ ProposalStatus,
+ PurchaseRecord,
+ RefundReason,
+ RefundState,
+ WalletContractData,
+ WalletStoresV1,
+} from "../db.js";
+import {
+ makeErrorDetail,
+ makePendingOperationFailedError,
+ TalerError,
+ TalerProtocolViolationError,
+} from "../errors.js";
+import { GetReadWriteAccess } from "../index.browser.js";
+import {
+ EXCHANGE_COINS_LOCK,
+ InternalWalletState,
+} from "../internal-wallet-state.js";
+import { PendingTaskType } from "../pending-types.js";
+import { assertUnreachable } from "../util/assertUnreachable.js";
+import {
+ CoinSelectionTally,
+ PreviousPayCoins,
+ tallyFees,
+} from "../util/coinSelection.js";
+import {
+ getHttpResponseErrorDetails,
+ readSuccessResponseJsonOrErrorCode,
+ readSuccessResponseJsonOrThrow,
+ readTalerErrorResponse,
+ readUnexpectedResponseDetails,
+ throwUnexpectedRequestError,
+} from "../util/http.js";
+import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
+import {
+ OperationAttemptResult,
+ OperationAttemptResultType,
+ RetryInfo,
+ RetryTags,
+ scheduleRetry,
+} from "../util/retries.js";
+import {
+ spendCoins,
+ storeOperationPending,
+ storeOperationError,
+ makeEventId,
+} from "./common.js";
+import { getExchangeDetails } from "./exchanges.js";
+import { createRefreshGroup, getTotalRefreshCost } from "./refresh.js";
+
+/**
+ * Logger.
+ */
+const logger = new Logger("pay.ts");
+
+/**
+ * Compute the total cost of a payment to the customer.
+ *
+ * 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.
+ */
+export async function getTotalPaymentCost(
+ ws: InternalWalletState,
+ pcs: PayCoinSelection,
+): Promise<AmountJson> {
+ return ws.db
+ .mktx((x) => [x.coins, x.denominations])
+ .runReadOnly(async (tx) => {
+ const costs: AmountJson[] = [];
+ 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(
+ DenominationRecord.getValue(x),
+ pcs.coinContributions[i],
+ ),
+ );
+ const amountLeft = Amounts.sub(
+ DenominationRecord.getValue(denom),
+ pcs.coinContributions[i],
+ ).amount;
+ const refreshCost = getTotalRefreshCost(
+ allDenoms,
+ DenominationRecord.toDenomInfo(denom),
+ amountLeft,
+ );
+ costs.push(pcs.coinContributions[i]);
+ costs.push(refreshCost);
+ }
+ const zero = Amounts.getZero(pcs.paymentAmount.currency);
+ return Amounts.sum([zero, ...costs]).amount;
+ });
+}
+
+export interface CoinSelectionRequest {
+ amount: AmountJson;
+
+ allowedAuditors: AllowedAuditorInfo[];
+ allowedExchanges: AllowedExchangeInfo[];
+
+ /**
+ * Timestamp of the contract.
+ */
+ timestamp: TalerProtocolTimestamp;
+
+ 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;
+}
+
+async function failProposalPermanently(
+ ws: InternalWalletState,
+ proposalId: string,
+ err: TalerErrorDetail,
+): Promise<void> {
+ await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadWrite(async (tx) => {
+ const p = await tx.purchases.get(proposalId);
+ if (!p) {
+ return;
+ }
+ p.status = ProposalStatus.ProposalDownloadFailed;
+ await tx.purchases.put(p);
+ });
+}
+
+function getProposalRequestTimeout(retryInfo?: RetryInfo): Duration {
+ return Duration.clamp({
+ lower: Duration.fromSpec({ seconds: 1 }),
+ upper: Duration.fromSpec({ seconds: 60 }),
+ value: retryInfo ? RetryInfo.getDuration(retryInfo) : Duration.fromSpec({}),
+ });
+}
+
+function getPayRequestTimeout(purchase: PurchaseRecord): Duration {
+ return Duration.multiply(
+ { d_ms: 15000 },
+ 1 + (purchase.payInfo?.payCoinSelection.coinPubs.length ?? 0) / 5,
+ );
+}
+
+/**
+ * Return the proposal download data for a purchase, throw if not available.
+ *
+ * (Async since in the future this will query the DB.)
+ */
+export async function expectProposalDownload(
+ p: PurchaseRecord,
+): Promise<ProposalDownload> {
+ if (!p.download) {
+ throw Error("expected proposal to be downloaded");
+ }
+ return p.download;
+}
+
+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,
+ deliveryDate: parsedContractTerms.delivery_date,
+ deliveryLocation: parsedContractTerms.delivery_location,
+ };
+}
+
+export async function processDownloadProposal(
+ ws: InternalWalletState,
+ proposalId: string,
+ options: object = {},
+): Promise<OperationAttemptResult> {
+ const proposal = await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadOnly(async (tx) => {
+ return await tx.purchases.get(proposalId);
+ });
+
+ if (!proposal) {
+ return {
+ type: OperationAttemptResultType.Finished,
+ result: undefined,
+ };
+ }
+
+ if (proposal.status != ProposalStatus.DownloadingProposal) {
+ return {
+ type: OperationAttemptResultType.Finished,
+ result: undefined,
+ };
+ }
+
+ const orderClaimUrl = new URL(
+ `orders/${proposal.orderId}/claim`,
+ proposal.merchantBaseUrl,
+ ).href;
+ logger.trace("downloading contract from '" + orderClaimUrl + "'");
+
+ const requestBody: {
+ nonce: string;
+ token?: string;
+ } = {
+ nonce: proposal.noncePub,
+ };
+ if (proposal.claimToken) {
+ requestBody.token = proposal.claimToken;
+ }
+
+ const opId = RetryTags.forPay(proposal);
+ const retryRecord = await ws.db
+ .mktx((x) => [x.operationRetries])
+ .runReadOnly(async (tx) => {
+ return tx.operationRetries.get(opId);
+ });
+
+ // FIXME: Do this in the background using the new return value
+ const httpResponse = await ws.http.postJson(orderClaimUrl, requestBody, {
+ timeout: getProposalRequestTimeout(retryRecord?.retryInfo),
+ });
+ const r = await readSuccessResponseJsonOrErrorCode(
+ httpResponse,
+ codecForProposal(),
+ );
+ if (r.isError) {
+ switch (r.talerErrorResponse.code) {
+ 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;
+
+ // 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.
+
+ // 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,
+ );
+
+ if (!isWellFormed) {
+ logger.trace(
+ `malformed contract terms: ${j2s(proposalResp.contract_terms)}`,
+ );
+ const err = makeErrorDetail(
+ TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED,
+ {},
+ "validation for well-formedness failed",
+ );
+ await failProposalPermanently(ws, proposalId, err);
+ throw makePendingOperationFailedError(
+ err,
+ TransactionType.Payment,
+ proposalId,
+ );
+ }
+
+ const contractTermsHash = ContractTermsUtil.hashContractTerms(
+ proposalResp.contract_terms,
+ );
+
+ logger.info(`Contract terms hash: ${contractTermsHash}`);
+
+ let parsedContractTerms: ContractTerms;
+
+ try {
+ parsedContractTerms = codecForContractTerms().decode(
+ proposalResp.contract_terms,
+ );
+ } catch (e) {
+ const err = makeErrorDetail(
+ TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED,
+ {},
+ `schema validation failed: ${e}`,
+ );
+ await failProposalPermanently(ws, proposalId, err);
+ throw makePendingOperationFailedError(
+ err,
+ TransactionType.Payment,
+ proposalId,
+ );
+ }
+
+ const sigValid = await ws.cryptoApi.isValidContractTermsSignature({
+ contractTermsHash,
+ 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;
+
+ 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,
+ );
+ }
+
+ const contractData = extractContractData(
+ parsedContractTerms,
+ contractTermsHash,
+ proposalResp.sig,
+ );
+
+ logger.trace(`extracted contract data: ${j2s(contractData)}`);
+
+ await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadWrite(async (tx) => {
+ const p = await tx.purchases.get(proposalId);
+ if (!p) {
+ return;
+ }
+ if (p.status !== ProposalStatus.DownloadingProposal) {
+ return;
+ }
+ p.download = {
+ contractData,
+ contractTermsRaw: proposalResp.contract_terms,
+ };
+ if (
+ fulfillmentUrl &&
+ (fulfillmentUrl.startsWith("http://") ||
+ fulfillmentUrl.startsWith("https://"))
+ ) {
+ const differentPurchase =
+ await tx.purchases.indexes.byFulfillmentUrl.get(fulfillmentUrl);
+ if (differentPurchase) {
+ logger.warn("repurchase detected");
+ p.status = ProposalStatus.RepurchaseDetected;
+ p.repurchaseProposalId = differentPurchase.proposalId;
+ await tx.purchases.put(p);
+ return;
+ }
+ }
+ p.status = ProposalStatus.Proposed;
+ await tx.purchases.put(p);
+ });
+
+ ws.notify({
+ type: NotificationType.ProposalDownloaded,
+ proposalId: proposal.proposalId,
+ });
+
+ return {
+ type: OperationAttemptResultType.Finished,
+ result: undefined,
+ };
+}
+
+/**
+ * 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.
+ */
+async function startDownloadProposal(
+ ws: InternalWalletState,
+ merchantBaseUrl: string,
+ orderId: string,
+ sessionId: string | undefined,
+ claimToken: string | undefined,
+ noncePriv: string | undefined,
+): Promise<string> {
+ const oldProposal = await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadOnly(async (tx) => {
+ return tx.purchases.indexes.byUrlAndOrderId.get([
+ merchantBaseUrl,
+ orderId,
+ ]);
+ });
+
+ /* If we have already claimed this proposal with the same sessionId
+ * nonce and claim token, reuse it. */
+ if (
+ oldProposal &&
+ oldProposal.downloadSessionId === sessionId &&
+ (!noncePriv || oldProposal.noncePriv === noncePriv) &&
+ oldProposal.claimToken === claimToken
+ ) {
+ await processDownloadProposal(ws, oldProposal.proposalId);
+ return oldProposal.proposalId;
+ }
+
+ let noncePair: EddsaKeypair;
+ if (noncePriv) {
+ noncePair = {
+ priv: noncePriv,
+ pub: (await ws.cryptoApi.eddsaGetPublic({ priv: noncePriv })).pub,
+ };
+ } else {
+ noncePair = await ws.cryptoApi.createEddsaKeypair({});
+ }
+
+ const { priv, pub } = noncePair;
+ const proposalId = encodeCrock(getRandomBytes(32));
+
+ const proposalRecord: PurchaseRecord = {
+ download: undefined,
+ noncePriv: priv,
+ noncePub: pub,
+ claimToken,
+ timestamp: AbsoluteTime.toTimestamp(AbsoluteTime.now()),
+ merchantBaseUrl,
+ orderId,
+ proposalId: proposalId,
+ status: ProposalStatus.DownloadingProposal,
+ repurchaseProposalId: undefined,
+ downloadSessionId: sessionId,
+ autoRefundDeadline: undefined,
+ lastSessionId: undefined,
+ merchantPaySig: undefined,
+ payInfo: undefined,
+ refundAmountAwaiting: undefined,
+ refunds: {},
+ timestampAccept: undefined,
+ timestampFirstSuccessfulPay: undefined,
+ timestampLastRefundStatus: undefined,
+ pendingRemovedCoinPubs: undefined,
+ };
+
+ await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadWrite(async (tx) => {
+ const existingRecord = await tx.purchases.indexes.byUrlAndOrderId.get([
+ merchantBaseUrl,
+ orderId,
+ ]);
+ if (existingRecord) {
+ // Created concurrently
+ return;
+ }
+ await tx.purchases.put(proposalRecord);
+ });
+
+ await processDownloadProposal(ws, proposalId);
+ return proposalId;
+}
+
+async function storeFirstPaySuccess(
+ ws: InternalWalletState,
+ proposalId: string,
+ sessionId: string | undefined,
+ paySig: string,
+): Promise<void> {
+ const now = AbsoluteTime.toTimestamp(AbsoluteTime.now());
+ await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadWrite(async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+
+ if (!purchase) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
+ if (!isFirst) {
+ logger.warn("payment success already stored");
+ return;
+ }
+ if (purchase.status === ProposalStatus.Paying) {
+ purchase.status = ProposalStatus.Paid;
+ }
+ purchase.timestampFirstSuccessfulPay = now;
+ purchase.lastSessionId = sessionId;
+ purchase.merchantPaySig = paySig;
+ const protoAr = purchase.download!.contractData.autoRefund;
+ if (protoAr) {
+ const ar = Duration.fromTalerProtocolDuration(protoAr);
+ logger.info("auto_refund present");
+ purchase.status = ProposalStatus.QueryingAutoRefund;
+ purchase.autoRefundDeadline = AbsoluteTime.toTimestamp(
+ AbsoluteTime.addDuration(AbsoluteTime.now(), ar),
+ );
+ }
+ await tx.purchases.put(purchase);
+ });
+}
+
+async function storePayReplaySuccess(
+ ws: InternalWalletState,
+ proposalId: string,
+ sessionId: string | undefined,
+): Promise<void> {
+ await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadWrite(async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+
+ if (!purchase) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ const isFirst = purchase.timestampFirstSuccessfulPay === undefined;
+ if (isFirst) {
+ throw Error("invalid payment state");
+ }
+ if (purchase.status === ProposalStatus.Paying) {
+ purchase.status = ProposalStatus.Paid;
+ }
+ purchase.lastSessionId = sessionId;
+ await tx.purchases.put(purchase);
+ });
+}
+
+/**
+ * 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");
+
+ const proposal = await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadOnly(async (tx) => {
+ return tx.purchases.get(proposalId);
+ });
+ if (!proposal) {
+ return;
+ }
+
+ logger.trace(`got error details: ${j2s(err)}`);
+
+ 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})`);
+ }
+
+ const brokenCoinPub = (exchangeReply as any).coin_pub;
+ logger.trace(`excluded broken coin pub=${brokenCoinPub}`);
+
+ if (!brokenCoinPub) {
+ throw new TalerProtocolViolationError();
+ }
+
+ const { contractData } = proposal.download!;
+
+ const prevPayCoins: PreviousPayCoins = [];
+
+ const payInfo = proposal.payInfo;
+ if (!payInfo) {
+ return;
+ }
+
+ const payCoinSelection = payInfo.payCoinSelection;
+
+ await ws.db
+ .mktx((x) => [x.coins, x.denominations])
+ .runReadOnly(async (tx) => {
+ for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
+ const coinPub = payCoinSelection.coinPubs[i];
+ if (coinPub === brokenCoinPub) {
+ continue;
+ }
+ const contrib = 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.fees.feeDeposit,
+ });
+ }
+ });
+
+ const res = await selectPayCoinsNew(ws, {
+ auditors: contractData.allowedAuditors,
+ exchanges: contractData.allowedExchanges,
+ wireMethod: contractData.wireMethod,
+ 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");
+
+ await ws.db
+ .mktx((x) => [
+ x.purchases,
+ x.coins,
+ x.coinAvailability,
+ x.denominations,
+ x.refreshGroups,
+ ])
+ .runReadWrite(async (tx) => {
+ const p = await tx.purchases.get(proposalId);
+ if (!p) {
+ return;
+ }
+ const payInfo = p.payInfo;
+ if (!payInfo) {
+ return;
+ }
+ payInfo.payCoinSelection = res;
+ payInfo.payCoinSelection = res;
+ payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(32));
+ payInfo.coinDepositPermissions = undefined;
+ await tx.purchases.put(p);
+ await spendCoins(ws, tx, {
+ allocationId: `proposal:${p.proposalId}`,
+ coinPubs: payInfo.payCoinSelection.coinPubs,
+ contributions: payInfo.payCoinSelection.coinContributions,
+ refreshReason: RefreshReason.PayMerchant,
+ });
+ });
+}
+
+async function unblockBackup(
+ ws: InternalWalletState,
+ proposalId: string,
+): Promise<void> {
+ await ws.db
+ .mktx((x) => [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,
+ nextBackupTimestamp: TalerProtocolTimestamp.now(),
+ };
+ }
+ });
+ });
+}
+
+export interface SelectPayCoinRequestNg {
+ exchanges: AllowedExchangeInfo[];
+ auditors: AllowedAuditorInfo[];
+ wireMethod: string;
+ contractTermsAmount: AmountJson;
+ depositFeeLimit: AmountJson;
+ wireFeeLimit: AmountJson;
+ wireFeeAmortization: number;
+ prevPayCoins?: PreviousPayCoins;
+ requiredMinimumAge?: number;
+ forcedSelection?: ForcedCoinSel;
+}
+
+export type AvailableDenom = DenominationInfo & {
+ maxAge: number;
+ numAvailable: number;
+};
+
+export async function selectCandidates(
+ ws: InternalWalletState,
+ req: SelectPayCoinRequestNg,
+): Promise<[AvailableDenom[], Record<string, AmountJson>]> {
+ return await ws.db
+ .mktx((x) => [
+ x.exchanges,
+ x.exchangeDetails,
+ x.denominations,
+ x.coinAvailability,
+ ])
+ .runReadOnly(async (tx) => {
+ const denoms: AvailableDenom[] = [];
+ const exchanges = await tx.exchanges.iter().toArray();
+ const wfPerExchange: Record<string, AmountJson> = {};
+ for (const exchange of exchanges) {
+ const exchangeDetails = await getExchangeDetails(tx, exchange.baseUrl);
+ if (exchangeDetails?.currency !== req.contractTermsAmount.currency) {
+ continue;
+ }
+ let wireMethodSupported = false;
+ for (const acc of exchangeDetails.wireInfo.accounts) {
+ const pp = parsePaytoUri(acc.payto_uri);
+ checkLogicInvariant(!!pp);
+ if (pp.targetType === req.wireMethod) {
+ wireMethodSupported = true;
+ break;
+ }
+ }
+ if (!wireMethodSupported) {
+ break;
+ }
+ exchangeDetails.wireInfo.accounts;
+ let accepted = false;
+ for (const allowedExchange of req.exchanges) {
+ if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) {
+ accepted = true;
+ break;
+ }
+ }
+ for (const allowedAuditor of req.auditors) {
+ for (const providedAuditor of exchangeDetails.auditors) {
+ if (allowedAuditor.auditorPub === providedAuditor.auditor_pub) {
+ accepted = true;
+ break;
+ }
+ }
+ }
+ if (!accepted) {
+ continue;
+ }
+ let ageLower = 0;
+ let ageUpper = AgeRestriction.AGE_UNRESTRICTED;
+ if (req.requiredMinimumAge) {
+ ageLower = req.requiredMinimumAge;
+ }
+ const myExchangeDenoms =
+ await tx.coinAvailability.indexes.byExchangeAgeAvailability.getAll(
+ GlobalIDB.KeyRange.bound(
+ [exchangeDetails.exchangeBaseUrl, ageLower, 1],
+ [
+ exchangeDetails.exchangeBaseUrl,
+ ageUpper,
+ Number.MAX_SAFE_INTEGER,
+ ],
+ ),
+ );
+ // FIXME: Check that the individual denomination is audited!
+ // FIXME: Should we exclude denominations that are
+ // not spendable anymore?
+ for (const denomAvail of myExchangeDenoms) {
+ const denom = await tx.denominations.get([
+ denomAvail.exchangeBaseUrl,
+ denomAvail.denomPubHash,
+ ]);
+ checkDbInvariant(!!denom);
+ if (denom.isRevoked || !denom.isOffered) {
+ continue;
+ }
+ denoms.push({
+ ...DenominationRecord.toDenomInfo(denom),
+ numAvailable: denomAvail.freshCoinCount ?? 0,
+ maxAge: denomAvail.maxAge,
+ });
+ }
+ }
+ // Sort by available amount (descending), deposit fee (ascending) and
+ // denomPub (ascending) if deposit fee is the same
+ // (to guarantee deterministic results)
+ denoms.sort(
+ (o1, o2) =>
+ -Amounts.cmp(o1.value, o2.value) ||
+ Amounts.cmp(o1.feeDeposit, o2.feeDeposit) ||
+ strcmp(o1.denomPubHash, o2.denomPubHash),
+ );
+ return [denoms, wfPerExchange];
+ });
+}
+
+function makeAvailabilityKey(
+ exchangeBaseUrl: string,
+ denomPubHash: string,
+ maxAge: number,
+): string {
+ return `${denomPubHash};${maxAge};${exchangeBaseUrl}`;
+}
+
+/**
+ * Selection result.
+ */
+interface SelResult {
+ /**
+ * Map from an availability key
+ * to an array of contributions.
+ */
+ [avKey: string]: {
+ exchangeBaseUrl: string;
+ denomPubHash: string;
+ maxAge: number;
+ contributions: AmountJson[];
+ };
+}
+
+export function selectGreedy(
+ req: SelectPayCoinRequestNg,
+ candidateDenoms: AvailableDenom[],
+ wireFeesPerExchange: Record<string, AmountJson>,
+ tally: CoinSelectionTally,
+): SelResult | undefined {
+ const { wireFeeAmortization } = req;
+ const selectedDenom: SelResult = {};
+ for (const aci of candidateDenoms) {
+ const contributions: AmountJson[] = [];
+ for (let i = 0; i < aci.numAvailable; i++) {
+ // Don't use this coin if depositing it is more expensive than
+ // the amount it would give the merchant.
+ if (Amounts.cmp(aci.feeDeposit, aci.value) > 0) {
+ continue;
+ }
+
+ if (Amounts.isZero(tally.amountPayRemaining)) {
+ // We have spent enough!
+ break;
+ }
+
+ tally = tallyFees(
+ tally,
+ wireFeesPerExchange,
+ wireFeeAmortization,
+ aci.exchangeBaseUrl,
+ aci.feeDeposit,
+ );
+
+ let coinSpend = Amounts.max(
+ Amounts.min(tally.amountPayRemaining, aci.value),
+ aci.feeDeposit,
+ );
+
+ tally.amountPayRemaining = Amounts.sub(
+ tally.amountPayRemaining,
+ coinSpend,
+ ).amount;
+ contributions.push(coinSpend);
+ }
+
+ if (contributions.length) {
+ const avKey = makeAvailabilityKey(
+ aci.exchangeBaseUrl,
+ aci.denomPubHash,
+ aci.maxAge,
+ );
+ let sd = selectedDenom[avKey];
+ if (!sd) {
+ sd = {
+ contributions: [],
+ denomPubHash: aci.denomPubHash,
+ exchangeBaseUrl: aci.exchangeBaseUrl,
+ maxAge: aci.maxAge,
+ };
+ }
+ sd.contributions.push(...contributions);
+ selectedDenom[avKey] = sd;
+ }
+
+ if (Amounts.isZero(tally.amountPayRemaining)) {
+ return selectedDenom;
+ }
+ }
+ return undefined;
+}
+
+export function selectForced(
+ req: SelectPayCoinRequestNg,
+ candidateDenoms: AvailableDenom[],
+): SelResult | undefined {
+ const selectedDenom: SelResult = {};
+
+ const forcedSelection = req.forcedSelection;
+ checkLogicInvariant(!!forcedSelection);
+
+ for (const forcedCoin of forcedSelection.coins) {
+ let found = false;
+ for (const aci of candidateDenoms) {
+ if (aci.numAvailable <= 0) {
+ continue;
+ }
+ if (Amounts.cmp(aci.value, forcedCoin.value) === 0) {
+ aci.numAvailable--;
+ const avKey = makeAvailabilityKey(
+ aci.exchangeBaseUrl,
+ aci.denomPubHash,
+ aci.maxAge,
+ );
+ let sd = selectedDenom[avKey];
+ if (!sd) {
+ sd = {
+ contributions: [],
+ denomPubHash: aci.denomPubHash,
+ exchangeBaseUrl: aci.exchangeBaseUrl,
+ maxAge: aci.maxAge,
+ };
+ }
+ sd.contributions.push(Amounts.parseOrThrow(forcedCoin.value));
+ selectedDenom[avKey] = sd;
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ throw Error("can't find coin for forced coin selection");
+ }
+ }
+
+ return selectedDenom;
+}
+
+/**
+ * Given a list of candidate coins, select coins to spend under the merchant's
+ * constraints.
+ *
+ * The prevPayCoins can be specified to "repair" a coin selection
+ * by adding additional coins, after a broken (e.g. double-spent) coin
+ * has been removed from the selection.
+ *
+ * This function is only exported for the sake of unit tests.
+ */
+export async function selectPayCoinsNew(
+ ws: InternalWalletState,
+ req: SelectPayCoinRequestNg,
+): Promise<PayCoinSelection | undefined> {
+ const {
+ contractTermsAmount,
+ depositFeeLimit,
+ wireFeeLimit,
+ wireFeeAmortization,
+ } = req;
+
+ const [candidateDenoms, wireFeesPerExchange] = await selectCandidates(
+ ws,
+ req,
+ );
+
+ // logger.trace(`candidate denoms: ${j2s(candidateDenoms)}`);
+
+ const coinPubs: string[] = [];
+ const coinContributions: AmountJson[] = [];
+ const currency = contractTermsAmount.currency;
+
+ let tally: CoinSelectionTally = {
+ amountPayRemaining: contractTermsAmount,
+ amountWireFeeLimitRemaining: wireFeeLimit,
+ amountDepositFeeLimitRemaining: depositFeeLimit,
+ customerDepositFees: Amounts.getZero(currency),
+ customerWireFees: Amounts.getZero(currency),
+ wireFeeCoveredForExchange: new Set(),
+ };
+
+ const prevPayCoins = req.prevPayCoins ?? [];
+
+ // Look at existing pay coin selection and tally up
+ for (const prev of prevPayCoins) {
+ tally = tallyFees(
+ tally,
+ wireFeesPerExchange,
+ wireFeeAmortization,
+ prev.exchangeBaseUrl,
+ prev.feeDeposit,
+ );
+ tally.amountPayRemaining = Amounts.sub(
+ tally.amountPayRemaining,
+ prev.contribution,
+ ).amount;
+
+ coinPubs.push(prev.coinPub);
+ coinContributions.push(prev.contribution);
+ }
+
+ let selectedDenom: SelResult | undefined;
+ if (req.forcedSelection) {
+ selectedDenom = selectForced(req, candidateDenoms);
+ } else {
+ // FIXME: Here, we should select coins in a smarter way.
+ // Instead of always spending the next-largest coin,
+ // we should try to find the smallest coin that covers the
+ // amount.
+ selectedDenom = selectGreedy(
+ req,
+ candidateDenoms,
+ wireFeesPerExchange,
+ tally,
+ );
+ }
+
+ if (!selectedDenom) {
+ return undefined;
+ }
+
+ const finalSel = selectedDenom;
+
+ logger.trace(`coin selection request ${j2s(req)}`);
+ logger.trace(`selected coins (via denoms) for payment: ${j2s(finalSel)}`);
+
+ await ws.db
+ .mktx((x) => [x.coins, x.denominations])
+ .runReadOnly(async (tx) => {
+ for (const dph of Object.keys(finalSel)) {
+ const selInfo = finalSel[dph];
+ const numRequested = selInfo.contributions.length;
+ const query = [
+ selInfo.exchangeBaseUrl,
+ selInfo.denomPubHash,
+ selInfo.maxAge,
+ CoinStatus.Fresh,
+ ];
+ logger.info(`query: ${j2s(query)}`);
+ const coins =
+ await tx.coins.indexes.byExchangeDenomPubHashAndAgeAndStatus.getAll(
+ query,
+ numRequested,
+ );
+ if (coins.length != numRequested) {
+ throw Error(
+ `coin selection failed (not available anymore, got only ${coins.length}/${numRequested})`,
+ );
+ }
+ coinPubs.push(...coins.map((x) => x.coinPub));
+ coinContributions.push(...selInfo.contributions);
+ }
+ });
+
+ return {
+ paymentAmount: contractTermsAmount,
+ coinContributions,
+ coinPubs,
+ customerDepositFees: tally.customerDepositFees,
+ customerWireFees: tally.customerWireFees,
+ };
+}
+
+export async function checkPaymentByProposalId(
+ ws: InternalWalletState,
+ proposalId: string,
+ sessionId?: string,
+): Promise<PreparePayResult> {
+ let proposal = await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadOnly(async (tx) => {
+ return tx.purchases.get(proposalId);
+ });
+ if (!proposal) {
+ throw Error(`could not get proposal ${proposalId}`);
+ }
+ if (proposal.status === ProposalStatus.RepurchaseDetected) {
+ const existingProposalId = proposal.repurchaseProposalId;
+ if (!existingProposalId) {
+ throw Error("invalid proposal state");
+ }
+ logger.trace("using existing purchase for same product");
+ proposal = await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadOnly(async (tx) => {
+ return tx.purchases.get(existingProposalId);
+ });
+ if (!proposal) {
+ throw Error("existing proposal is in wrong state");
+ }
+ }
+ const d = proposal.download;
+ if (!d) {
+ logger.error("bad proposal", proposal);
+ throw Error("proposal is in invalid state");
+ }
+ const contractData = d.contractData;
+ const merchantSig = d.contractData.merchantSig;
+ if (!merchantSig) {
+ throw Error("BUG: proposal is in invalid state");
+ }
+
+ proposalId = proposal.proposalId;
+
+ // First check if we already paid for it.
+ const purchase = await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadOnly(async (tx) => {
+ return tx.purchases.get(proposalId);
+ });
+
+ if (!purchase || purchase.status === ProposalStatus.Proposed) {
+ // If not already paid, check if we could pay for it.
+ const res = await selectPayCoinsNew(ws, {
+ auditors: contractData.allowedAuditors,
+ exchanges: contractData.allowedExchanges,
+ contractTermsAmount: contractData.amount,
+ depositFeeLimit: contractData.maxDepositFee,
+ wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
+ wireFeeLimit: contractData.maxWireFee,
+ prevPayCoins: [],
+ requiredMinimumAge: contractData.minimumAge,
+ wireMethod: contractData.wireMethod,
+ });
+
+ if (!res) {
+ logger.info("not allowing payment, insufficient coins");
+ return {
+ status: PreparePayResultType.InsufficientBalance,
+ contractTerms: d.contractTermsRaw,
+ proposalId: proposal.proposalId,
+ noncePriv: proposal.noncePriv,
+ amountRaw: Amounts.stringify(d.contractData.amount),
+ };
+ }
+
+ const totalCost = await getTotalPaymentCost(ws, res);
+ logger.trace("costInfo", totalCost);
+ logger.trace("coinsForPayment", res);
+
+ return {
+ status: PreparePayResultType.PaymentPossible,
+ contractTerms: d.contractTermsRaw,
+ proposalId: proposal.proposalId,
+ noncePriv: proposal.noncePriv,
+ amountEffective: Amounts.stringify(totalCost),
+ amountRaw: Amounts.stringify(res.paymentAmount),
+ contractTermsHash: d.contractData.contractTermsHash,
+ };
+ }
+
+ if (
+ purchase.status === ProposalStatus.Paid &&
+ purchase.lastSessionId !== sessionId
+ ) {
+ logger.trace(
+ "automatically re-submitting payment with different session ID",
+ );
+ logger.trace(`last: ${purchase.lastSessionId}, current: ${sessionId}`);
+ await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadWrite(async (tx) => {
+ const p = await tx.purchases.get(proposalId);
+ if (!p) {
+ return;
+ }
+ p.lastSessionId = sessionId;
+ p.status = ProposalStatus.PayingReplay;
+ await tx.purchases.put(p);
+ });
+ const r = await processPurchasePay(ws, proposalId, { forceNow: true });
+ if (r.type !== OperationAttemptResultType.Finished) {
+ // FIXME: This does not surface the original error
+ throw Error("submitting pay failed");
+ }
+ const download = await expectProposalDownload(purchase);
+ return {
+ status: PreparePayResultType.AlreadyConfirmed,
+ contractTerms: download.contractTermsRaw,
+ contractTermsHash: download.contractData.contractTermsHash,
+ paid: true,
+ amountRaw: Amounts.stringify(download.contractData.amount),
+ amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!),
+ proposalId,
+ };
+ } else if (!purchase.timestampFirstSuccessfulPay) {
+ const download = await expectProposalDownload(purchase);
+ return {
+ status: PreparePayResultType.AlreadyConfirmed,
+ contractTerms: download.contractTermsRaw,
+ contractTermsHash: download.contractData.contractTermsHash,
+ paid: false,
+ amountRaw: Amounts.stringify(download.contractData.amount),
+ amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!),
+ proposalId,
+ };
+ } else {
+ const paid =
+ purchase.status === ProposalStatus.Paid ||
+ purchase.status === ProposalStatus.QueryingRefund ||
+ purchase.status === ProposalStatus.QueryingAutoRefund;
+ const download = await expectProposalDownload(purchase);
+ return {
+ status: PreparePayResultType.AlreadyConfirmed,
+ contractTerms: download.contractTermsRaw,
+ contractTermsHash: download.contractData.contractTermsHash,
+ paid,
+ amountRaw: Amounts.stringify(download.contractData.amount),
+ amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!),
+ ...(paid ? { nextUrl: download.contractData.orderId } : {}),
+ proposalId,
+ };
+ }
+}
+
+export async function getContractTermsDetails(
+ ws: InternalWalletState,
+ proposalId: string,
+): Promise<WalletContractData> {
+ const proposal = await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadOnly(async (tx) => {
+ return tx.purchases.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;
+}
+
+/**
+ * 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,
+ uriResult.noncePriv,
+ );
+
+ return checkPaymentByProposalId(ws, proposalId, uriResult.sessionId);
+}
+
+/**
+ * Generate deposit permissions for a purchase.
+ *
+ * Accesses the database and the crypto worker.
+ */
+export async function generateDepositPermissions(
+ ws: InternalWalletState,
+ payCoinSel: PayCoinSelection,
+ contractData: WalletContractData,
+): Promise<CoinDepositPermission[]> {
+ const depositPermissions: CoinDepositPermission[] = [];
+ const coinWithDenom: Array<{
+ coin: CoinRecord;
+ denom: DenominationRecord;
+ }> = [];
+ await ws.db
+ .mktx((x) => [x.coins, 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 });
+ }
+ });
+
+ for (let i = 0; i < payCoinSel.coinPubs.length; i++) {
+ const { coin, denom } = coinWithDenom[i];
+ let wireInfoHash: string;
+ wireInfoHash = contractData.wireInfoHash;
+ logger.trace(
+ `signing deposit permission for coin with ageRestriction=${j2s(
+ coin.ageCommitmentProof,
+ )}`,
+ );
+ const dp = await ws.cryptoApi.signDepositPermission({
+ coinPriv: coin.coinPriv,
+ coinPub: coin.coinPub,
+ contractTermsHash: contractData.contractTermsHash,
+ denomPubHash: coin.denomPubHash,
+ denomKeyType: denom.denomPub.cipher,
+ denomSig: coin.denomSig,
+ exchangeBaseUrl: coin.exchangeBaseUrl,
+ feeDeposit: denom.fees.feeDeposit,
+ merchantPub: contractData.merchantPub,
+ refundDeadline: contractData.refundDeadline,
+ spendAmount: payCoinSel.coinContributions[i],
+ timestamp: contractData.timestamp,
+ wireInfoHash,
+ ageCommitmentProof: coin.ageCommitmentProof,
+ requiredMinimumAge: contractData.minimumAge,
+ });
+ depositPermissions.push(dp);
+ }
+ return depositPermissions;
+}
+
+/**
+ * Run the operation handler for a payment
+ * and return the result as a {@link ConfirmPayResult}.
+ */
+export async function runPayForConfirmPay(
+ ws: InternalWalletState,
+ proposalId: string,
+): Promise<ConfirmPayResult> {
+ const res = await processPurchasePay(ws, proposalId, { forceNow: true });
+ switch (res.type) {
+ case OperationAttemptResultType.Finished: {
+ const purchase = await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadOnly(async (tx) => {
+ return tx.purchases.get(proposalId);
+ });
+ if (!purchase?.download) {
+ throw Error("purchase record not available anymore");
+ }
+ return {
+ type: ConfirmPayResultType.Done,
+ contractTerms: purchase.download.contractTermsRaw,
+ transactionId: makeEventId(TransactionType.Payment, proposalId),
+ };
+ }
+ case OperationAttemptResultType.Error: {
+ // We hide transient errors from the caller.
+ const opRetry = await ws.db
+ .mktx((x) => [x.operationRetries])
+ .runReadOnly(async (tx) =>
+ tx.operationRetries.get(RetryTags.byPaymentProposalId(proposalId)),
+ );
+ const maxRetry = 3;
+ const numRetry = opRetry?.retryInfo.retryCounter ?? 0;
+ if (
+ res.errorDetail.code ===
+ TalerErrorCode.WALLET_PAY_MERCHANT_SERVER_ERROR &&
+ numRetry < maxRetry
+ ) {
+ // Pretend the operation is pending instead of reporting
+ // an error, but only up to maxRetry attempts.
+ await storeOperationPending(
+ ws,
+ RetryTags.byPaymentProposalId(proposalId),
+ );
+ return {
+ type: ConfirmPayResultType.Pending,
+ lastError: opRetry?.lastError,
+ transactionId: makeEventId(TransactionType.Payment, proposalId),
+ };
+ } else {
+ // FIXME: allocate error code!
+ await storeOperationError(
+ ws,
+ RetryTags.byPaymentProposalId(proposalId),
+ res.errorDetail,
+ );
+ throw Error("payment failed");
+ }
+ }
+ case OperationAttemptResultType.Pending:
+ await storeOperationPending(
+ ws,
+ `${PendingTaskType.Purchase}:${proposalId}`,
+ );
+ return {
+ type: ConfirmPayResultType.Pending,
+ transactionId: makeEventId(TransactionType.Payment, proposalId),
+ lastError: undefined,
+ };
+ case OperationAttemptResultType.Longpoll:
+ throw Error("unexpected processPurchasePay result (longpoll)");
+ default:
+ assertUnreachable(res);
+ }
+}
+
+/**
+ * Confirm payment for a proposal previously claimed by the wallet.
+ */
+export async function confirmPay(
+ ws: InternalWalletState,
+ proposalId: string,
+ sessionIdOverride?: string,
+ forcedCoinSel?: ForcedCoinSel,
+): Promise<ConfirmPayResult> {
+ logger.trace(
+ `executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`,
+ );
+ const proposal = await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadOnly(async (tx) => {
+ return tx.purchases.get(proposalId);
+ });
+
+ if (!proposal) {
+ throw Error(`proposal with id ${proposalId} not found`);
+ }
+
+ const d = proposal.download;
+ if (!d) {
+ throw Error("proposal is in invalid state");
+ }
+
+ const existingPurchase = await ws.db
+ .mktx((x) => [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;
+ await tx.purchases.put(purchase);
+ }
+ return purchase;
+ });
+
+ if (existingPurchase && existingPurchase.payInfo) {
+ logger.trace("confirmPay: submitting payment for existing purchase");
+ return runPayForConfirmPay(ws, proposalId);
+ }
+
+ logger.trace("confirmPay: purchase record does not exist yet");
+
+ const contractData = d.contractData;
+
+ let maybeCoinSelection: PayCoinSelection | undefined = undefined;
+
+ maybeCoinSelection = await selectPayCoinsNew(ws, {
+ auditors: contractData.allowedAuditors,
+ exchanges: contractData.allowedExchanges,
+ wireMethod: contractData.wireMethod,
+ contractTermsAmount: contractData.amount,
+ depositFeeLimit: contractData.maxDepositFee,
+ wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
+ wireFeeLimit: contractData.maxWireFee,
+ prevPayCoins: [],
+ requiredMinimumAge: contractData.minimumAge,
+ forcedSelection: forcedCoinSel,
+ });
+
+ logger.trace("coin selection result", maybeCoinSelection);
+
+ if (!maybeCoinSelection) {
+ // 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.
+ logger.warn("not confirming payment, insufficient coins");
+ throw Error("insufficient balance");
+ }
+
+ const coinSelection = maybeCoinSelection;
+
+ const depositPermissions = await generateDepositPermissions(
+ ws,
+ coinSelection,
+ d.contractData,
+ );
+
+ const payCostInfo = await getTotalPaymentCost(ws, coinSelection);
+
+ let sessionId: string | undefined;
+ if (sessionIdOverride) {
+ sessionId = sessionIdOverride;
+ } else {
+ sessionId = proposal.downloadSessionId;
+ }
+
+ logger.trace(
+ `recording payment on ${proposal.orderId} with session ID ${sessionId}`,
+ );
+
+ await ws.db
+ .mktx((x) => [
+ x.purchases,
+ x.coins,
+ x.refreshGroups,
+ x.denominations,
+ x.coinAvailability,
+ ])
+ .runReadWrite(async (tx) => {
+ const p = await tx.purchases.get(proposal.proposalId);
+ if (!p) {
+ return;
+ }
+ switch (p.status) {
+ case ProposalStatus.Proposed:
+ p.payInfo = {
+ payCoinSelection: coinSelection,
+ payCoinSelectionUid: encodeCrock(getRandomBytes(16)),
+ totalPayCost: payCostInfo,
+ coinDepositPermissions: depositPermissions,
+ };
+ p.lastSessionId = sessionId;
+ p.timestampAccept = TalerProtocolTimestamp.now();
+ p.status = ProposalStatus.Paying;
+ await tx.purchases.put(p);
+ await spendCoins(ws, tx, {
+ allocationId: `proposal:${p.proposalId}`,
+ coinPubs: coinSelection.coinPubs,
+ contributions: coinSelection.coinContributions,
+ refreshReason: RefreshReason.PayMerchant,
+ });
+ break;
+ case ProposalStatus.Paid:
+ case ProposalStatus.Paying:
+ default:
+ break;
+ }
+ });
+
+ ws.notify({
+ type: NotificationType.ProposalAccepted,
+ proposalId: proposal.proposalId,
+ });
+
+ return runPayForConfirmPay(ws, proposalId);
+}
+
+export async function processPurchase(
+ ws: InternalWalletState,
+ proposalId: string,
+ options: {
+ forceNow?: boolean;
+ } = {},
+): Promise<OperationAttemptResult> {
+ const purchase = await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadOnly(async (tx) => {
+ return tx.purchases.get(proposalId);
+ });
+ if (!purchase) {
+ return {
+ type: OperationAttemptResultType.Error,
+ errorDetail: {
+ // 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,
+ },
+ };
+ }
+
+ switch (purchase.status) {
+ case ProposalStatus.DownloadingProposal:
+ return processDownloadProposal(ws, proposalId, options);
+ case ProposalStatus.Paying:
+ case ProposalStatus.PayingReplay:
+ return processPurchasePay(ws, proposalId, options);
+ case ProposalStatus.QueryingAutoRefund:
+ case ProposalStatus.QueryingAutoRefund:
+ case ProposalStatus.AbortingWithRefund:
+ return processPurchaseQueryRefund(ws, proposalId, options);
+ case ProposalStatus.ProposalDownloadFailed:
+ case ProposalStatus.Paid:
+ case ProposalStatus.AbortingWithRefund:
+ case ProposalStatus.RepurchaseDetected:
+ return {
+ type: OperationAttemptResultType.Finished,
+ result: undefined,
+ };
+ default:
+ throw Error(`unexpected purchase status (${purchase.status})`);
+ }
+}
+
+export async function processPurchasePay(
+ ws: InternalWalletState,
+ proposalId: string,
+ options: {
+ forceNow?: boolean;
+ } = {},
+): Promise<OperationAttemptResult> {
+ const purchase = await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadOnly(async (tx) => {
+ return tx.purchases.get(proposalId);
+ });
+ if (!purchase) {
+ return {
+ type: OperationAttemptResultType.Error,
+ errorDetail: {
+ // 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,
+ },
+ };
+ }
+ switch (purchase.status) {
+ case ProposalStatus.Paying:
+ case ProposalStatus.PayingReplay:
+ break;
+ default:
+ return OperationAttemptResult.finishedEmpty();
+ }
+ logger.trace(`processing purchase pay ${proposalId}`);
+
+ const sessionId = purchase.lastSessionId;
+
+ logger.trace(`paying with session ID ${sessionId}`);
+ const payInfo = purchase.payInfo;
+ checkDbInvariant(!!payInfo, "payInfo");
+
+ const download = await expectProposalDownload(purchase);
+ if (!purchase.merchantPaySig) {
+ const payUrl = new URL(
+ `orders/${download.contractData.orderId}/pay`,
+ download.contractData.merchantBaseUrl,
+ ).href;
+
+ let depositPermissions: CoinDepositPermission[];
+
+ if (purchase.payInfo?.coinDepositPermissions) {
+ depositPermissions = purchase.payInfo.coinDepositPermissions;
+ } else {
+ // FIXME: also cache!
+ depositPermissions = await generateDepositPermissions(
+ ws,
+ payInfo.payCoinSelection,
+ 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)}`);
+
+ if (resp.status >= 500 && resp.status <= 599) {
+ const errDetails = await readUnexpectedResponseDetails(resp);
+ return {
+ type: OperationAttemptResultType.Error,
+ errorDetail: makeErrorDetail(
+ TalerErrorCode.WALLET_PAY_MERCHANT_SERVER_ERROR,
+ {
+ requestError: errDetails,
+ },
+ ),
+ };
+ }
+
+ 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) => [x.purchases])
+ .runReadWrite(async (tx) => {
+ const purch = await tx.purchases.get(proposalId);
+ if (!purch) {
+ return;
+ }
+ // FIXME: Should be some "PayPermanentlyFailed" and error info should be stored
+ purch.status = ProposalStatus.PaymentAbortFinished;
+ 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) => {
+ console.log("handling insufficient funds failed");
+
+ await scheduleRetry(ws, RetryTags.forPay(purchase), {
+ code: TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
+ message: "unexpected exception",
+ hint: "unexpected exception",
+ details: {
+ exception: e.toString(),
+ },
+ });
+ });
+
+ return {
+ type: OperationAttemptResultType.Pending,
+ result: undefined,
+ };
+ }
+ }
+
+ const merchantResp = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForMerchantPayResponse(),
+ );
+
+ logger.trace("got success from pay URL", merchantResp);
+
+ const merchantPub = download.contractData.merchantPub;
+ const { valid } = await ws.cryptoApi.isValidPaymentSignature({
+ contractHash: download.contractData.contractTermsHash,
+ merchantPub,
+ 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/${download.contractData.orderId}/paid`,
+ download.contractData.merchantBaseUrl,
+ ).href;
+ const reqBody = {
+ sig: purchase.merchantPaySig,
+ h_contract: download.contractData.contractTermsHash,
+ session_id: sessionId ?? "",
+ };
+ logger.trace(`/paid request body: ${j2s(reqBody)}`);
+ const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
+ ws.http.postJson(payAgainUrl, reqBody),
+ );
+ logger.trace(`/paid response status: ${resp.status}`);
+ 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 OperationAttemptResult.finishedEmpty();
+}
+
+export async function refuseProposal(
+ ws: InternalWalletState,
+ proposalId: string,
+): Promise<void> {
+ const success = await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadWrite(async (tx) => {
+ const proposal = await tx.purchases.get(proposalId);
+ if (!proposal) {
+ logger.trace(`proposal ${proposalId} not found, won't refuse proposal`);
+ return false;
+ }
+ if (proposal.status !== ProposalStatus.Proposed) {
+ return false;
+ }
+ proposal.status = ProposalStatus.ProposalRefused;
+ await tx.purchases.put(proposal);
+ return true;
+ });
+ if (success) {
+ ws.notify({
+ type: NotificationType.ProposalRefused,
+ });
+ }
+}
+
+export async function prepareRefund(
+ ws: InternalWalletState,
+ talerRefundUri: string,
+): Promise<PrepareRefundResult> {
+ const parseResult = parseRefundUri(talerRefundUri);
+
+ logger.trace("preparing refund offer", parseResult);
+
+ if (!parseResult) {
+ throw Error("invalid refund URI");
+ }
+
+ const purchase = await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadOnly(async (tx) => {
+ return tx.purchases.indexes.byMerchantUrlAndOrderId.get([
+ parseResult.merchantBaseUrl,
+ parseResult.orderId,
+ ]);
+ });
+
+ if (!purchase) {
+ throw Error(
+ `no purchase for the taler://refund/ URI (${talerRefundUri}) was found`,
+ );
+ }
+
+ const awaiting = await queryAndSaveAwaitingRefund(ws, purchase);
+ const summary = await calculateRefundSummary(purchase);
+ const proposalId = purchase.proposalId;
+
+ const { contractData: c } = await expectProposalDownload(purchase);
+
+ return {
+ proposalId,
+ effectivePaid: Amounts.stringify(summary.amountEffectivePaid),
+ gone: Amounts.stringify(summary.amountRefundGone),
+ granted: Amounts.stringify(summary.amountRefundGranted),
+ pending: summary.pendingAtExchange,
+ awaiting: Amounts.stringify(awaiting),
+ info: {
+ contractTermsHash: c.contractTermsHash,
+ merchant: c.merchant,
+ orderId: c.orderId,
+ products: c.products,
+ summary: c.summary,
+ fulfillmentMessage: c.fulfillmentMessage,
+ summary_i18n: c.summaryI18n,
+ fulfillmentMessage_i18n: c.fulfillmentMessageI18n,
+ },
+ };
+}
+
+function getRefundKey(d: MerchantCoinRefundStatus): string {
+ return `${d.coin_pub}-${d.rtransaction_id}`;
+}
+
+async function applySuccessfulRefund(
+ tx: GetReadWriteAccess<{
+ coins: typeof WalletStoresV1.coins;
+ denominations: typeof WalletStoresV1.denominations;
+ }>,
+ p: PurchaseRecord,
+ refreshCoinsMap: Record<string, { coinPub: string }>,
+ r: MerchantCoinRefundSuccessStatus,
+): Promise<void> {
+ // FIXME: check signature before storing it as valid!
+
+ const refundKey = getRefundKey(r);
+ const coin = await tx.coins.get(r.coin_pub);
+ if (!coin) {
+ logger.warn("coin not found, can't apply refund");
+ return;
+ }
+ const denom = await tx.denominations.get([
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ ]);
+ if (!denom) {
+ throw Error("inconsistent database");
+ }
+ refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub };
+ const refundAmount = Amounts.parseOrThrow(r.refund_amount);
+ const refundFee = denom.fees.feeRefund;
+ coin.status = CoinStatus.Dormant;
+ coin.currentAmount = Amounts.add(coin.currentAmount, refundAmount).amount;
+ coin.currentAmount = Amounts.sub(coin.currentAmount, refundFee).amount;
+ logger.trace(`coin amount after is ${Amounts.stringify(coin.currentAmount)}`);
+ await tx.coins.put(coin);
+
+ const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
+ .iter(coin.exchangeBaseUrl)
+ .toArray();
+
+ const amountLeft = Amounts.sub(
+ Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
+ .amount,
+ denom.fees.feeRefund,
+ ).amount;
+
+ const totalRefreshCostBound = getTotalRefreshCost(
+ allDenoms,
+ DenominationRecord.toDenomInfo(denom),
+ amountLeft,
+ );
+
+ p.refunds[refundKey] = {
+ type: RefundState.Applied,
+ obtainedTime: AbsoluteTime.toTimestamp(AbsoluteTime.now()),
+ executionTime: r.execution_time,
+ refundAmount: Amounts.parseOrThrow(r.refund_amount),
+ refundFee: denom.fees.feeRefund,
+ totalRefreshCostBound,
+ coinPub: r.coin_pub,
+ rtransactionId: r.rtransaction_id,
+ };
+}
+
+async function storePendingRefund(
+ tx: GetReadWriteAccess<{
+ denominations: typeof WalletStoresV1.denominations;
+ coins: typeof WalletStoresV1.coins;
+ }>,
+ p: PurchaseRecord,
+ r: MerchantCoinRefundFailureStatus,
+): Promise<void> {
+ const refundKey = getRefundKey(r);
+
+ const coin = await tx.coins.get(r.coin_pub);
+ if (!coin) {
+ logger.warn("coin not found, can't apply refund");
+ return;
+ }
+ const denom = await tx.denominations.get([
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ ]);
+
+ if (!denom) {
+ throw Error("inconsistent database");
+ }
+
+ const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
+ .iter(coin.exchangeBaseUrl)
+ .toArray();
+
+ const amountLeft = Amounts.sub(
+ Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
+ .amount,
+ denom.fees.feeRefund,
+ ).amount;
+
+ const totalRefreshCostBound = getTotalRefreshCost(
+ allDenoms,
+ DenominationRecord.toDenomInfo(denom),
+ amountLeft,
+ );
+
+ p.refunds[refundKey] = {
+ type: RefundState.Pending,
+ obtainedTime: AbsoluteTime.toTimestamp(AbsoluteTime.now()),
+ executionTime: r.execution_time,
+ refundAmount: Amounts.parseOrThrow(r.refund_amount),
+ refundFee: denom.fees.feeRefund,
+ totalRefreshCostBound,
+ coinPub: r.coin_pub,
+ rtransactionId: r.rtransaction_id,
+ };
+}
+
+async function storeFailedRefund(
+ tx: GetReadWriteAccess<{
+ coins: typeof WalletStoresV1.coins;
+ denominations: typeof WalletStoresV1.denominations;
+ }>,
+ p: PurchaseRecord,
+ refreshCoinsMap: Record<string, { coinPub: string }>,
+ r: MerchantCoinRefundFailureStatus,
+): Promise<void> {
+ const refundKey = getRefundKey(r);
+
+ const coin = await tx.coins.get(r.coin_pub);
+ if (!coin) {
+ logger.warn("coin not found, can't apply refund");
+ return;
+ }
+ const denom = await tx.denominations.get([
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ ]);
+
+ if (!denom) {
+ throw Error("inconsistent database");
+ }
+
+ const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
+ .iter(coin.exchangeBaseUrl)
+ .toArray();
+
+ const amountLeft = Amounts.sub(
+ Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
+ .amount,
+ denom.fees.feeRefund,
+ ).amount;
+
+ const totalRefreshCostBound = getTotalRefreshCost(
+ allDenoms,
+ DenominationRecord.toDenomInfo(denom),
+ amountLeft,
+ );
+
+ p.refunds[refundKey] = {
+ type: RefundState.Failed,
+ obtainedTime: TalerProtocolTimestamp.now(),
+ executionTime: r.execution_time,
+ refundAmount: Amounts.parseOrThrow(r.refund_amount),
+ refundFee: denom.fees.feeRefund,
+ totalRefreshCostBound,
+ coinPub: r.coin_pub,
+ rtransactionId: r.rtransaction_id,
+ };
+
+ if (p.status === ProposalStatus.AbortingWithRefund) {
+ // Refund failed because the merchant didn't even try to deposit
+ // the coin yet, so we try to refresh.
+ if (r.exchange_code === TalerErrorCode.EXCHANGE_REFUND_DEPOSIT_NOT_FOUND) {
+ const coin = await tx.coins.get(r.coin_pub);
+ if (!coin) {
+ logger.warn("coin not found, can't apply refund");
+ return;
+ }
+ const denom = await tx.denominations.get([
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ ]);
+ if (!denom) {
+ logger.warn("denomination for coin missing");
+ return;
+ }
+ const payCoinSelection = p.payInfo?.payCoinSelection;
+ if (!payCoinSelection) {
+ logger.warn("no pay coin selection, can't apply refund");
+ return;
+ }
+ let contrib: AmountJson | undefined;
+ for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
+ if (payCoinSelection.coinPubs[i] === r.coin_pub) {
+ contrib = payCoinSelection.coinContributions[i];
+ }
+ }
+ if (contrib) {
+ coin.currentAmount = Amounts.add(coin.currentAmount, contrib).amount;
+ coin.currentAmount = Amounts.sub(
+ coin.currentAmount,
+ denom.fees.feeRefund,
+ ).amount;
+ }
+ refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub };
+ await tx.coins.put(coin);
+ }
+ }
+}
+
+async function acceptRefunds(
+ ws: InternalWalletState,
+ proposalId: string,
+ refunds: MerchantCoinRefundStatus[],
+ reason: RefundReason,
+): Promise<void> {
+ logger.trace("handling refunds", refunds);
+ const now = TalerProtocolTimestamp.now();
+
+ await ws.db
+ .mktx((x) => [
+ x.purchases,
+ x.coins,
+ x.coinAvailability,
+ x.denominations,
+ x.refreshGroups,
+ ])
+ .runReadWrite(async (tx) => {
+ const p = await tx.purchases.get(proposalId);
+ if (!p) {
+ logger.error("purchase not found, not adding refunds");
+ return;
+ }
+
+ const refreshCoinsMap: Record<string, CoinPublicKey> = {};
+
+ for (const refundStatus of refunds) {
+ const refundKey = getRefundKey(refundStatus);
+ const existingRefundInfo = p.refunds[refundKey];
+
+ const isPermanentFailure =
+ refundStatus.type === "failure" &&
+ refundStatus.exchange_status >= 400 &&
+ refundStatus.exchange_status < 500;
+
+ // Already failed.
+ if (existingRefundInfo?.type === RefundState.Failed) {
+ continue;
+ }
+
+ // Already applied.
+ if (existingRefundInfo?.type === RefundState.Applied) {
+ continue;
+ }
+
+ // Still pending.
+ if (
+ refundStatus.type === "failure" &&
+ !isPermanentFailure &&
+ existingRefundInfo?.type === RefundState.Pending
+ ) {
+ continue;
+ }
+
+ // Invariant: (!existingRefundInfo) || (existingRefundInfo === Pending)
+
+ if (refundStatus.type === "success") {
+ await applySuccessfulRefund(tx, p, refreshCoinsMap, refundStatus);
+ } else if (isPermanentFailure) {
+ await storeFailedRefund(tx, p, refreshCoinsMap, refundStatus);
+ } else {
+ await storePendingRefund(tx, p, refundStatus);
+ }
+ }
+
+ const refreshCoinsPubs = Object.values(refreshCoinsMap);
+ if (refreshCoinsPubs.length > 0) {
+ await createRefreshGroup(
+ ws,
+ tx,
+ refreshCoinsPubs,
+ RefreshReason.Refund,
+ );
+ }
+
+ // Are we done with querying yet, or do we need to do another round
+ // after a retry delay?
+ let queryDone = true;
+
+ let numPendingRefunds = 0;
+ for (const ri of Object.values(p.refunds)) {
+ switch (ri.type) {
+ case RefundState.Pending:
+ numPendingRefunds++;
+ break;
+ }
+ }
+
+ if (numPendingRefunds > 0) {
+ queryDone = false;
+ }
+
+ if (queryDone) {
+ p.timestampLastRefundStatus = now;
+ if (p.status === ProposalStatus.AbortingWithRefund) {
+ p.status = ProposalStatus.PaymentAbortFinished;
+ } else if (p.status === ProposalStatus.QueryingAutoRefund) {
+ const autoRefundDeadline = p.autoRefundDeadline;
+ checkDbInvariant(!!autoRefundDeadline);
+ if (
+ AbsoluteTime.isExpired(
+ AbsoluteTime.fromTimestamp(autoRefundDeadline),
+ )
+ ) {
+ p.status = ProposalStatus.Paid;
+ }
+ } else if (p.status === ProposalStatus.QueryingRefund) {
+ p.status = ProposalStatus.Paid;
+ }
+ logger.trace("refund query done");
+ } else {
+ // No error, but we need to try again!
+ p.timestampLastRefundStatus = now;
+ logger.trace("refund query not done");
+ }
+
+ await tx.purchases.put(p);
+ });
+
+ ws.notify({
+ type: NotificationType.RefundQueried,
+ });
+}
+
+async function calculateRefundSummary(
+ p: PurchaseRecord,
+): Promise<RefundSummary> {
+ const download = await expectProposalDownload(p);
+ let amountRefundGranted = Amounts.getZero(
+ download.contractData.amount.currency,
+ );
+ let amountRefundGone = Amounts.getZero(download.contractData.amount.currency);
+
+ let pendingAtExchange = false;
+
+ const payInfo = p.payInfo;
+ if (!payInfo) {
+ throw Error("can't calculate refund summary without payInfo");
+ }
+
+ Object.keys(p.refunds).forEach((rk) => {
+ const refund = p.refunds[rk];
+ if (refund.type === RefundState.Pending) {
+ pendingAtExchange = true;
+ }
+ if (
+ refund.type === RefundState.Applied ||
+ refund.type === RefundState.Pending
+ ) {
+ amountRefundGranted = Amounts.add(
+ amountRefundGranted,
+ Amounts.sub(
+ refund.refundAmount,
+ refund.refundFee,
+ refund.totalRefreshCostBound,
+ ).amount,
+ ).amount;
+ } else {
+ amountRefundGone = Amounts.add(
+ amountRefundGone,
+ refund.refundAmount,
+ ).amount;
+ }
+ });
+ return {
+ amountEffectivePaid: payInfo.totalPayCost,
+ amountRefundGone,
+ amountRefundGranted,
+ pendingAtExchange,
+ };
+}
+
+/**
+ * Summary of the refund status of a purchase.
+ */
+export interface RefundSummary {
+ pendingAtExchange: boolean;
+ amountEffectivePaid: AmountJson;
+ amountRefundGranted: AmountJson;
+ amountRefundGone: AmountJson;
+}
+
+/**
+ * Accept a refund, return the contract hash for the contract
+ * that was involved in the refund.
+ */
+export async function applyRefund(
+ ws: InternalWalletState,
+ talerRefundUri: string,
+): Promise<ApplyRefundResponse> {
+ const parseResult = parseRefundUri(talerRefundUri);
+
+ logger.trace("applying refund", parseResult);
+
+ if (!parseResult) {
+ throw Error("invalid refund URI");
+ }
+
+ const purchase = await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadOnly(async (tx) => {
+ return tx.purchases.indexes.byMerchantUrlAndOrderId.get([
+ parseResult.merchantBaseUrl,
+ parseResult.orderId,
+ ]);
+ });
+
+ if (!purchase) {
+ throw Error(
+ `no purchase for the taler://refund/ URI (${talerRefundUri}) was found`,
+ );
+ }
+
+ return applyRefundFromPurchaseId(ws, purchase.proposalId);
+}
+
+export async function applyRefundFromPurchaseId(
+ ws: InternalWalletState,
+ proposalId: string,
+): Promise<ApplyRefundResponse> {
+ logger.trace("applying refund for purchase", proposalId);
+
+ logger.info("processing purchase for refund");
+ const success = await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadWrite(async (tx) => {
+ const p = await tx.purchases.get(proposalId);
+ if (!p) {
+ logger.error("no purchase found for refund URL");
+ return false;
+ }
+ if (p.status === ProposalStatus.Paid) {
+ p.status = ProposalStatus.QueryingRefund;
+ }
+ await tx.purchases.put(p);
+ return true;
+ });
+
+ if (success) {
+ ws.notify({
+ type: NotificationType.RefundStarted,
+ });
+ await processPurchaseQueryRefund(ws, proposalId, {
+ forceNow: true,
+ waitForAutoRefund: false,
+ });
+ }
+
+ const purchase = await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadOnly(async (tx) => {
+ return tx.purchases.get(proposalId);
+ });
+
+ if (!purchase) {
+ throw Error("purchase no longer exists");
+ }
+
+ const summary = await calculateRefundSummary(purchase);
+ const download = await expectProposalDownload(purchase);
+
+ return {
+ contractTermsHash: download.contractData.contractTermsHash,
+ proposalId: purchase.proposalId,
+ transactionId: makeEventId(TransactionType.Payment, proposalId), //FIXME: can we have the tx id of the refund
+ amountEffectivePaid: Amounts.stringify(summary.amountEffectivePaid),
+ amountRefundGone: Amounts.stringify(summary.amountRefundGone),
+ amountRefundGranted: Amounts.stringify(summary.amountRefundGranted),
+ pendingAtExchange: summary.pendingAtExchange,
+ info: {
+ contractTermsHash: download.contractData.contractTermsHash,
+ merchant: download.contractData.merchant,
+ orderId: download.contractData.orderId,
+ products: download.contractData.products,
+ summary: download.contractData.summary,
+ fulfillmentMessage: download.contractData.fulfillmentMessage,
+ summary_i18n: download.contractData.summaryI18n,
+ fulfillmentMessage_i18n: download.contractData.fulfillmentMessageI18n,
+ },
+ };
+}
+
+async function queryAndSaveAwaitingRefund(
+ ws: InternalWalletState,
+ purchase: PurchaseRecord,
+ waitForAutoRefund?: boolean,
+): Promise<AmountJson> {
+ const download = await expectProposalDownload(purchase);
+ const requestUrl = new URL(
+ `orders/${download.contractData.orderId}`,
+ download.contractData.merchantBaseUrl,
+ );
+ requestUrl.searchParams.set(
+ "h_contract",
+ download.contractData.contractTermsHash,
+ );
+ // Long-poll for one second
+ if (waitForAutoRefund) {
+ requestUrl.searchParams.set("timeout_ms", "1000");
+ requestUrl.searchParams.set("await_refund_obtained", "yes");
+ logger.trace("making long-polling request for auto-refund");
+ }
+ const resp = await ws.http.get(requestUrl.href);
+ const orderStatus = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForMerchantOrderStatusPaid(),
+ );
+ if (!orderStatus.refunded) {
+ // Wait for retry ...
+ return Amounts.getZero(download.contractData.amount.currency);
+ }
+
+ const refundAwaiting = Amounts.sub(
+ Amounts.parseOrThrow(orderStatus.refund_amount),
+ Amounts.parseOrThrow(orderStatus.refund_taken),
+ ).amount;
+
+ if (
+ purchase.refundAmountAwaiting === undefined ||
+ Amounts.cmp(refundAwaiting, purchase.refundAmountAwaiting) !== 0
+ ) {
+ await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadWrite(async (tx) => {
+ const p = await tx.purchases.get(purchase.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ p.refundAmountAwaiting = refundAwaiting;
+ await tx.purchases.put(p);
+ });
+ }
+
+ return refundAwaiting;
+}
+
+export async function processPurchaseQueryRefund(
+ ws: InternalWalletState,
+ proposalId: string,
+ options: {
+ forceNow?: boolean;
+ waitForAutoRefund?: boolean;
+ } = {},
+): Promise<OperationAttemptResult> {
+ logger.trace(`processing refund query for proposal ${proposalId}`);
+ const waitForAutoRefund = options.waitForAutoRefund ?? false;
+ const purchase = await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadOnly(async (tx) => {
+ return tx.purchases.get(proposalId);
+ });
+ if (!purchase) {
+ return OperationAttemptResult.finishedEmpty();
+ }
+
+ if (
+ !(
+ purchase.status === ProposalStatus.QueryingAutoRefund ||
+ purchase.status === ProposalStatus.QueryingRefund ||
+ purchase.status === ProposalStatus.AbortingWithRefund
+ )
+ ) {
+ return OperationAttemptResult.finishedEmpty();
+ }
+
+ const download = await expectProposalDownload(purchase);
+
+ if (purchase.timestampFirstSuccessfulPay) {
+ if (
+ !purchase.autoRefundDeadline ||
+ !AbsoluteTime.isExpired(
+ AbsoluteTime.fromTimestamp(purchase.autoRefundDeadline),
+ )
+ ) {
+ const awaitingAmount = await queryAndSaveAwaitingRefund(
+ ws,
+ purchase,
+ waitForAutoRefund,
+ );
+ if (Amounts.isZero(awaitingAmount)) {
+ return OperationAttemptResult.finishedEmpty();
+ }
+ }
+
+ const requestUrl = new URL(
+ `orders/${download.contractData.orderId}/refund`,
+ download.contractData.merchantBaseUrl,
+ );
+
+ logger.trace(`making refund request to ${requestUrl.href}`);
+
+ const request = await ws.http.postJson(requestUrl.href, {
+ h_contract: download.contractData.contractTermsHash,
+ });
+
+ const refundResponse = await readSuccessResponseJsonOrThrow(
+ request,
+ codecForMerchantOrderRefundPickupResponse(),
+ );
+
+ await acceptRefunds(
+ ws,
+ proposalId,
+ refundResponse.refunds,
+ RefundReason.NormalRefund,
+ );
+ } else if (purchase.status === ProposalStatus.AbortingWithRefund) {
+ const requestUrl = new URL(
+ `orders/${download.contractData.orderId}/abort`,
+ download.contractData.merchantBaseUrl,
+ );
+
+ const abortingCoins: AbortingCoin[] = [];
+
+ const payCoinSelection = purchase.payInfo?.payCoinSelection;
+ if (!payCoinSelection) {
+ throw Error("can't abort, no coins selected");
+ }
+
+ await ws.db
+ .mktx((x) => [x.coins])
+ .runReadOnly(async (tx) => {
+ for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
+ const coinPub = payCoinSelection.coinPubs[i];
+ const coin = await tx.coins.get(coinPub);
+ checkDbInvariant(!!coin, "expected coin to be present");
+ abortingCoins.push({
+ coin_pub: coinPub,
+ contribution: Amounts.stringify(
+ payCoinSelection.coinContributions[i],
+ ),
+ exchange_url: coin.exchangeBaseUrl,
+ });
+ }
+ });
+
+ const abortReq: AbortRequest = {
+ h_contract: download.contractData.contractTermsHash,
+ coins: abortingCoins,
+ };
+
+ logger.trace(`making order abort request to ${requestUrl.href}`);
+
+ const request = await ws.http.postJson(requestUrl.href, abortReq);
+ const abortResp = await readSuccessResponseJsonOrThrow(
+ request,
+ codecForAbortResponse(),
+ );
+
+ const refunds: MerchantCoinRefundStatus[] = [];
+
+ if (abortResp.refunds.length != abortingCoins.length) {
+ // FIXME: define error code!
+ throw Error("invalid order abort response");
+ }
+
+ for (let i = 0; i < abortResp.refunds.length; i++) {
+ const r = abortResp.refunds[i];
+ refunds.push({
+ ...r,
+ coin_pub: payCoinSelection.coinPubs[i],
+ refund_amount: Amounts.stringify(payCoinSelection.coinContributions[i]),
+ rtransaction_id: 0,
+ execution_time: AbsoluteTime.toTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.fromTimestamp(download.contractData.timestamp),
+ Duration.fromSpec({ seconds: 1 }),
+ ),
+ ),
+ });
+ }
+ await acceptRefunds(ws, proposalId, refunds, RefundReason.AbortRefund);
+ }
+ return OperationAttemptResult.finishedEmpty();
+}
+
+export async function abortFailedPayWithRefund(
+ ws: InternalWalletState,
+ proposalId: string,
+): Promise<void> {
+ await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadWrite(async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+ if (!purchase) {
+ throw Error("purchase not found");
+ }
+ if (purchase.timestampFirstSuccessfulPay) {
+ // No point in aborting it. We don't even report an error.
+ logger.warn(`tried to abort successful payment`);
+ return;
+ }
+ if (purchase.status === ProposalStatus.Paying) {
+ purchase.status = ProposalStatus.AbortingWithRefund;
+ }
+ await tx.purchases.put(purchase);
+ });
+ processPurchaseQueryRefund(ws, proposalId, {
+ forceNow: true,
+ }).catch((e) => {
+ logger.trace(`error during refund processing after abort pay: ${e}`);
+ });
+}