/*
This file is part of GNU Taler
(C) 2019 GNUnet e.V.
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
*/
import { AmountJson } from "../util/amounts";
import {
Auditor,
ExchangeHandle,
MerchantRefundResponse,
PayReq,
Proposal,
ContractTerms,
MerchantRefundPermission,
RefundRequest,
} from "../types/talerTypes";
import {
Timestamp,
CoinSelectionResult,
CoinWithDenom,
PayCoinInfo,
getTimestampNow,
PreparePayResult,
ConfirmPayResult,
OperationError,
} from "../types/walletTypes";
import {
Database
} from "../util/query";
import {
Stores,
CoinStatus,
DenominationRecord,
ProposalRecord,
PurchaseRecord,
CoinRecord,
ProposalStatus,
initRetryInfo,
updateRetryInfoTimeout,
} from "../types/dbTypes";
import * as Amounts from "../util/amounts";
import {
amountToPretty,
strcmp,
canonicalJson,
extractTalerStampOrThrow,
extractTalerDurationOrThrow,
extractTalerDuration,
} from "../util/helpers";
import { Logger } from "../util/logging";
import { InternalWalletState } from "./state";
import {
parsePayUri,
parseRefundUri,
getOrderDownloadUrl,
} from "../util/taleruri";
import { getTotalRefreshCost, refresh } from "./refresh";
import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto";
import { guardOperationException } from "./errors";
import { assertUnreachable } from "../util/assertUnreachable";
import { NotificationType } from "../types/notifications";
export interface SpeculativePayData {
payCoinInfo: PayCoinInfo;
exchangeUrl: string;
orderDownloadId: string;
proposal: ProposalRecord;
}
interface CoinsForPaymentArgs {
allowedAuditors: Auditor[];
allowedExchanges: ExchangeHandle[];
depositFeeLimit: AmountJson;
paymentAmount: AmountJson;
wireFeeAmortization: number;
wireFeeLimit: AmountJson;
wireFeeTime: Timestamp;
wireMethod: string;
}
interface SelectPayCoinsResult {
cds: CoinWithDenom[];
totalFees: AmountJson;
}
const logger = new Logger("pay.ts");
/**
* Select coins for a payment under the merchant's constraints.
*
* @param denoms all available denoms, used to compute refresh fees
*/
export function selectPayCoins(
denoms: DenominationRecord[],
cds: CoinWithDenom[],
paymentAmount: AmountJson,
depositFeeLimit: AmountJson,
): SelectPayCoinsResult | undefined {
if (cds.length === 0) {
return undefined;
}
// Sort by ascending deposit fee and denomPub if deposit fee is the same
// (to guarantee deterministic results)
cds.sort(
(o1, o2) =>
Amounts.cmp(o1.denom.feeDeposit, o2.denom.feeDeposit) ||
strcmp(o1.denom.denomPub, o2.denom.denomPub),
);
const currency = cds[0].denom.value.currency;
const cdsResult: CoinWithDenom[] = [];
let accDepositFee: AmountJson = Amounts.getZero(currency);
let accAmount: AmountJson = Amounts.getZero(currency);
for (const { coin, denom } of cds) {
if (coin.suspended) {
continue;
}
if (coin.status !== CoinStatus.Fresh) {
continue;
}
if (Amounts.cmp(denom.feeDeposit, coin.currentAmount) >= 0) {
continue;
}
cdsResult.push({ coin, denom });
accDepositFee = Amounts.add(denom.feeDeposit, accDepositFee).amount;
let leftAmount = Amounts.sub(
coin.currentAmount,
Amounts.sub(paymentAmount, accAmount).amount,
).amount;
accAmount = Amounts.add(coin.currentAmount, accAmount).amount;
const coversAmount = Amounts.cmp(accAmount, paymentAmount) >= 0;
const coversAmountWithFee =
Amounts.cmp(
accAmount,
Amounts.add(paymentAmount, denom.feeDeposit).amount,
) >= 0;
const isBelowFee = Amounts.cmp(accDepositFee, depositFeeLimit) <= 0;
logger.trace("candidate coin selection", {
coversAmount,
isBelowFee,
accDepositFee,
accAmount,
paymentAmount,
});
if ((coversAmount && isBelowFee) || coversAmountWithFee) {
const depositFeeToCover = Amounts.sub(accDepositFee, depositFeeLimit)
.amount;
leftAmount = Amounts.sub(leftAmount, depositFeeToCover).amount;
logger.trace("deposit fee to cover", amountToPretty(depositFeeToCover));
let totalFees: AmountJson = Amounts.getZero(currency);
if (coversAmountWithFee && !isBelowFee) {
// these are the fees the customer has to pay
// because the merchant doesn't cover them
totalFees = Amounts.sub(depositFeeLimit, accDepositFee).amount;
}
totalFees = Amounts.add(
totalFees,
getTotalRefreshCost(denoms, denom, leftAmount),
).amount;
return { cds: cdsResult, totalFees };
}
}
return undefined;
}
/**
* Get exchanges and associated coins that are still spendable, but only
* if the sum the coins' remaining value covers the payment amount and fees.
*/
async function getCoinsForPayment(
ws: InternalWalletState,
args: CoinsForPaymentArgs,
): Promise {
const {
allowedAuditors,
allowedExchanges,
depositFeeLimit,
paymentAmount,
wireFeeAmortization,
wireFeeLimit,
wireFeeTime,
wireMethod,
} = args;
let remainingAmount = paymentAmount;
const exchanges = await ws.db.iter(Stores.exchanges).toArray();
for (const exchange of exchanges) {
let isOkay: boolean = false;
const exchangeDetails = exchange.details;
if (!exchangeDetails) {
continue;
}
const exchangeFees = exchange.wireInfo;
if (!exchangeFees) {
continue;
}
// is the exchange explicitly allowed?
for (const allowedExchange of allowedExchanges) {
if (allowedExchange.master_pub === exchangeDetails.masterPublicKey) {
isOkay = true;
break;
}
}
// is the exchange allowed because of one of its auditors?
if (!isOkay) {
for (const allowedAuditor of allowedAuditors) {
for (const auditor of exchangeDetails.auditors) {
if (auditor.auditor_pub === allowedAuditor.auditor_pub) {
isOkay = true;
break;
}
}
if (isOkay) {
break;
}
}
}
if (!isOkay) {
continue;
}
const coins = await ws.db.iterIndex(
Stores.coins.exchangeBaseUrlIndex,
exchange.baseUrl,
).toArray();
const denoms = await ws.db.iterIndex(
Stores.denominations.exchangeBaseUrlIndex,
exchange.baseUrl,
).toArray();
if (!coins || coins.length === 0) {
continue;
}
// Denomination of the first coin, we assume that all other
// coins have the same currency
const firstDenom = await ws.db.get(Stores.denominations, [
exchange.baseUrl,
coins[0].denomPub,
]);
if (!firstDenom) {
throw Error("db inconsistent");
}
const currency = firstDenom.value.currency;
const cds: CoinWithDenom[] = [];
for (const coin of coins) {
const denom = await ws.db.get(Stores.denominations, [
exchange.baseUrl,
coin.denomPub,
]);
if (!denom) {
throw Error("db inconsistent");
}
if (denom.value.currency !== currency) {
console.warn(
`same pubkey for different currencies at exchange ${exchange.baseUrl}`,
);
continue;
}
if (coin.suspended) {
continue;
}
if (coin.status !== CoinStatus.Fresh) {
continue;
}
cds.push({ coin, denom });
}
let totalFees = Amounts.getZero(currency);
let wireFee: AmountJson | undefined;
for (const fee of exchangeFees.feesForType[wireMethod] || []) {
if (fee.startStamp <= wireFeeTime && fee.endStamp >= wireFeeTime) {
wireFee = fee.wireFee;
break;
}
}
if (wireFee) {
const amortizedWireFee = Amounts.divide(wireFee, wireFeeAmortization);
if (Amounts.cmp(wireFeeLimit, amortizedWireFee) < 0) {
totalFees = Amounts.add(amortizedWireFee, totalFees).amount;
remainingAmount = Amounts.add(amortizedWireFee, remainingAmount).amount;
}
}
const res = selectPayCoins(denoms, cds, remainingAmount, depositFeeLimit);
if (res) {
totalFees = Amounts.add(totalFees, res.totalFees).amount;
return {
cds: res.cds,
exchangeUrl: exchange.baseUrl,
totalAmount: remainingAmount,
totalFees,
};
}
}
return undefined;
}
/**
* Record all information that is necessary to
* pay for a proposal in the wallet's database.
*/
async function recordConfirmPay(
ws: InternalWalletState,
proposal: ProposalRecord,
payCoinInfo: PayCoinInfo,
chosenExchange: string,
sessionIdOverride: string | undefined,
): Promise {
const d = proposal.download;
if (!d) {
throw Error("proposal is in invalid state");
}
let sessionId;
if (sessionIdOverride) {
sessionId = sessionIdOverride;
} else {
sessionId = proposal.downloadSessionId;
}
logger.trace(`recording payment with session ID ${sessionId}`);
const payReq: PayReq = {
coins: payCoinInfo.sigs,
merchant_pub: d.contractTerms.merchant_pub,
mode: "pay",
order_id: d.contractTerms.order_id,
};
const t: PurchaseRecord = {
abortDone: false,
abortRequested: false,
contractTerms: d.contractTerms,
contractTermsHash: d.contractTermsHash,
lastSessionId: sessionId,
merchantSig: d.merchantSig,
payReq,
refundsDone: {},
refundsPending: {},
acceptTimestamp: getTimestampNow(),
lastRefundStatusTimestamp: undefined,
proposalId: proposal.proposalId,
lastPayError: undefined,
lastRefundStatusError: undefined,
payRetryInfo: initRetryInfo(),
refundStatusRetryInfo: initRetryInfo(),
refundStatusRequested: false,
lastRefundApplyError: undefined,
refundApplyRetryInfo: initRetryInfo(),
firstSuccessfulPayTimestamp: undefined,
autoRefundDeadline: undefined,
paymentSubmitPending: true,
};
await ws.db.runWithWriteTransaction(
[Stores.coins, Stores.purchases, Stores.proposals],
async tx => {
const p = await tx.get(Stores.proposals, proposal.proposalId);
if (p) {
p.proposalStatus = ProposalStatus.ACCEPTED;
p.lastError = undefined;
p.retryInfo = initRetryInfo(false);
await tx.put(Stores.proposals, p);
}
await tx.put(Stores.purchases, t);
for (let c of payCoinInfo.updatedCoins) {
await tx.put(Stores.coins, c);
}
},
);
ws.notify({
type: NotificationType.ProposalAccepted,
proposalId: proposal.proposalId,
});
return t;
}
function getNextUrl(contractTerms: ContractTerms): string {
const f = contractTerms.fulfillment_url;
if (f.startsWith("http://") || f.startsWith("https://")) {
const fu = new URL(contractTerms.fulfillment_url);
fu.searchParams.set("order_id", contractTerms.order_id);
return fu.href;
} else {
return f;
}
}
export async function abortFailedPayment(
ws: InternalWalletState,
proposalId: string,
): Promise {
const purchase = await ws.db.get(Stores.purchases, proposalId);
if (!purchase) {
throw Error("Purchase not found, unable to abort with refund");
}
if (purchase.firstSuccessfulPayTimestamp) {
throw Error("Purchase already finished, not aborting");
}
if (purchase.abortDone) {
console.warn("abort requested on already aborted purchase");
return;
}
purchase.abortRequested = true;
// From now on, we can't retry payment anymore,
// so mark this in the DB in case the /pay abort
// does not complete on the first try.
await ws.db.put(Stores.purchases, purchase);
let resp;
const abortReq = { ...purchase.payReq, mode: "abort-refund" };
const payUrl = new URL("pay", purchase.contractTerms.merchant_base_url).href;
try {
resp = await ws.http.postJson(payUrl, abortReq);
} catch (e) {
// Gives the user the option to retry / abort and refresh
console.log("aborting payment failed", e);
throw e;
}
if (resp.status !== 200) {
throw Error(`unexpected status for /pay (${resp.status})`);
}
const refundResponse = MerchantRefundResponse.checked(await resp.json());
await acceptRefundResponse(ws, purchase.proposalId, refundResponse);
await ws.db.runWithWriteTransaction([Stores.purchases], async tx => {
const p = await tx.get(Stores.purchases, proposalId);
if (!p) {
return;
}
p.abortDone = true;
await tx.put(Stores.purchases, p);
});
}
async function incrementProposalRetry(
ws: InternalWalletState,
proposalId: string,
err: OperationError | undefined,
): Promise {
await ws.db.runWithWriteTransaction([Stores.proposals], async tx => {
const pr = await tx.get(Stores.proposals, proposalId);
if (!pr) {
return;
}
if (!pr.retryInfo) {
return;
}
pr.retryInfo.retryCounter++;
updateRetryInfoTimeout(pr.retryInfo);
pr.lastError = err;
await tx.put(Stores.proposals, pr);
});
ws.notify({ type: NotificationType.ProposalOperationError });
}
async function incrementPurchasePayRetry(
ws: InternalWalletState,
proposalId: string,
err: OperationError | undefined,
): Promise {
console.log("incrementing purchase pay retry with error", err);
await ws.db.runWithWriteTransaction([Stores.purchases], async tx => {
const pr = await tx.get(Stores.purchases, proposalId);
if (!pr) {
return;
}
if (!pr.payRetryInfo) {
return;
}
pr.payRetryInfo.retryCounter++;
updateRetryInfoTimeout(pr.payRetryInfo);
pr.lastPayError = err;
await tx.put(Stores.purchases, pr);
});
ws.notify({ type: NotificationType.PayOperationError });
}
async function incrementPurchaseQueryRefundRetry(
ws: InternalWalletState,
proposalId: string,
err: OperationError | undefined,
): Promise {
console.log("incrementing purchase refund query retry with error", err);
await ws.db.runWithWriteTransaction([Stores.purchases], async tx => {
const pr = await tx.get(Stores.purchases, proposalId);
if (!pr) {
return;
}
if (!pr.refundStatusRetryInfo) {
return;
}
pr.refundStatusRetryInfo.retryCounter++;
updateRetryInfoTimeout(pr.refundStatusRetryInfo);
pr.lastRefundStatusError = err;
await tx.put(Stores.purchases, pr);
});
ws.notify({ type: NotificationType.RefundStatusOperationError });
}
async function incrementPurchaseApplyRefundRetry(
ws: InternalWalletState,
proposalId: string,
err: OperationError | undefined,
): Promise {
console.log("incrementing purchase refund apply retry with error", err);
await ws.db.runWithWriteTransaction([Stores.purchases], async tx => {
const pr = await tx.get(Stores.purchases, proposalId);
if (!pr) {
return;
}
if (!pr.refundApplyRetryInfo) {
return;
}
pr.refundApplyRetryInfo.retryCounter++;
updateRetryInfoTimeout(pr.refundStatusRetryInfo);
pr.lastRefundApplyError = err;
await tx.put(Stores.purchases, pr);
});
ws.notify({ type: NotificationType.RefundApplyOperationError });
}
export async function processDownloadProposal(
ws: InternalWalletState,
proposalId: string,
forceNow: boolean = false,
): Promise {
const onOpErr = (err: OperationError) =>
incrementProposalRetry(ws, proposalId, err);
await guardOperationException(
() => processDownloadProposalImpl(ws, proposalId, forceNow),
onOpErr,
);
}
async function resetDownloadProposalRetry(
ws: InternalWalletState,
proposalId: string,
) {
await ws.db.mutate(Stores.proposals, proposalId, x => {
if (x.retryInfo.active) {
x.retryInfo = initRetryInfo();
}
return x;
});
}
async function processDownloadProposalImpl(
ws: InternalWalletState,
proposalId: string,
forceNow: boolean,
): Promise {
if (forceNow) {
await resetDownloadProposalRetry(ws, proposalId);
}
const proposal = await ws.db.get(Stores.proposals, proposalId);
if (!proposal) {
return;
}
if (proposal.proposalStatus != ProposalStatus.DOWNLOADING) {
return;
}
const parsedUrl = new URL(
getOrderDownloadUrl(proposal.merchantBaseUrl, proposal.orderId),
);
parsedUrl.searchParams.set("nonce", proposal.noncePub);
const urlWithNonce = parsedUrl.href;
console.log("downloading contract from '" + urlWithNonce + "'");
let resp;
try {
resp = await ws.http.get(urlWithNonce);
} catch (e) {
console.log("contract download failed", e);
throw e;
}
if (resp.status !== 200) {
throw Error(`contract download failed with status ${resp.status}`);
}
const proposalResp = Proposal.checked(await resp.json());
const contractTermsHash = await ws.cryptoApi.hashString(
canonicalJson(proposalResp.contract_terms),
);
const fulfillmentUrl = proposalResp.contract_terms.fulfillment_url;
await ws.db.runWithWriteTransaction(
[Stores.proposals, Stores.purchases],
async tx => {
const p = await tx.get(Stores.proposals, proposalId);
if (!p) {
return;
}
if (p.proposalStatus !== ProposalStatus.DOWNLOADING) {
return;
}
if (
fulfillmentUrl.startsWith("http://") ||
fulfillmentUrl.startsWith("https://")
) {
const differentPurchase = await tx.getIndexed(
Stores.purchases.fulfillmentUrlIndex,
fulfillmentUrl,
);
if (differentPurchase) {
console.log("repurchase detected");
p.proposalStatus = ProposalStatus.REPURCHASE;
p.repurchaseProposalId = differentPurchase.proposalId;
await tx.put(Stores.proposals, p);
return;
}
}
p.download = {
contractTerms: proposalResp.contract_terms,
merchantSig: proposalResp.sig,
contractTermsHash,
};
p.proposalStatus = ProposalStatus.PROPOSED;
await tx.put(Stores.proposals, p);
},
);
ws.notify({
type: NotificationType.ProposalDownloaded,
proposalId: proposal.proposalId,
});
}
/**
* 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,
): Promise {
const oldProposal = await ws.db.getIndexed(
Stores.proposals.urlAndOrderIdIndex,
[merchantBaseUrl, orderId],
);
if (oldProposal) {
await processDownloadProposal(ws, oldProposal.proposalId);
return oldProposal.proposalId;
}
const { priv, pub } = await ws.cryptoApi.createEddsaKeypair();
const proposalId = encodeCrock(getRandomBytes(32));
const proposalRecord: ProposalRecord = {
download: undefined,
noncePriv: priv,
noncePub: pub,
timestamp: getTimestampNow(),
merchantBaseUrl,
orderId,
proposalId: proposalId,
proposalStatus: ProposalStatus.DOWNLOADING,
repurchaseProposalId: undefined,
retryInfo: initRetryInfo(),
lastError: undefined,
downloadSessionId: sessionId,
};
await ws.db.runWithWriteTransaction([Stores.proposals], async (tx) => {
const existingRecord = await tx.getIndexed(Stores.proposals.urlAndOrderIdIndex, [
merchantBaseUrl,
orderId,
]);
if (existingRecord) {
// Created concurrently
return;
}
await tx.put(Stores.proposals, proposalRecord);
});
await processDownloadProposal(ws, proposalId);
return proposalId;
}
export async function submitPay(
ws: InternalWalletState,
proposalId: string,
): Promise {
const purchase = await ws.db.get(Stores.purchases, proposalId);
if (!purchase) {
throw Error("Purchase not found: " + proposalId);
}
if (purchase.abortRequested) {
throw Error("not submitting payment for aborted purchase");
}
const sessionId = purchase.lastSessionId;
let resp;
const payReq = { ...purchase.payReq, session_id: sessionId };
console.log("paying with session ID", sessionId);
const payUrl = new URL("pay", purchase.contractTerms.merchant_base_url).href;
try {
resp = await ws.http.postJson(payUrl, payReq);
} catch (e) {
// Gives the user the option to retry / abort and refresh
console.log("payment failed", e);
throw e;
}
if (resp.status !== 200) {
throw Error(`unexpected status (${resp.status}) for /pay`);
}
const merchantResp = await resp.json();
console.log("got success from pay URL", merchantResp);
const merchantPub = purchase.contractTerms.merchant_pub;
const valid: boolean = await ws.cryptoApi.isValidPaymentSignature(
merchantResp.sig,
purchase.contractTermsHash,
merchantPub,
);
if (!valid) {
console.error("merchant payment signature invalid");
// FIXME: properly display error
throw Error("merchant payment signature invalid");
}
const isFirst = purchase.firstSuccessfulPayTimestamp === undefined;
purchase.firstSuccessfulPayTimestamp = getTimestampNow();
purchase.paymentSubmitPending = false;
purchase.lastPayError = undefined;
purchase.payRetryInfo = initRetryInfo(false);
if (isFirst) {
const ar = purchase.contractTerms.auto_refund;
if (ar) {
console.log("auto_refund present");
const autoRefundDelay = extractTalerDuration(ar);
console.log("auto_refund valid", autoRefundDelay);
if (autoRefundDelay) {
purchase.refundStatusRequested = true;
purchase.refundStatusRetryInfo = initRetryInfo();
purchase.lastRefundStatusError = undefined;
purchase.autoRefundDeadline = {
t_ms: getTimestampNow().t_ms + autoRefundDelay.d_ms,
};
}
}
}
const modifiedCoins: CoinRecord[] = [];
for (const pc of purchase.payReq.coins) {
const c = await ws.db.get(Stores.coins, pc.coin_pub);
if (!c) {
console.error("coin not found");
throw Error("coin used in payment not found");
}
c.status = CoinStatus.Dirty;
modifiedCoins.push(c);
}
await ws.db.runWithWriteTransaction(
[Stores.coins, Stores.purchases],
async tx => {
for (let c of modifiedCoins) {
await tx.put(Stores.coins, c);
}
await tx.put(Stores.purchases, purchase);
},
);
for (const c of purchase.payReq.coins) {
refresh(ws, c.coin_pub).catch(e => {
console.log("error in refreshing after payment:", e);
});
}
const nextUrl = getNextUrl(purchase.contractTerms);
ws.cachedNextUrl[purchase.contractTerms.fulfillment_url] = {
nextUrl,
lastSessionId: sessionId,
};
return { nextUrl };
}
/**
* Check if a payment for the given taler://pay/ URI is possible.
*
* If the payment is possible, the signature are already generated but not
* yet send to the merchant.
*/
export async function preparePay(
ws: InternalWalletState,
talerPayUri: string,
): Promise {
const uriResult = parsePayUri(talerPayUri);
if (!uriResult) {
return {
status: "error",
error: "URI not supported",
};
}
let proposalId = await startDownloadProposal(
ws,
uriResult.merchantBaseUrl,
uriResult.orderId,
uriResult.sessionId,
);
let proposal = await ws.db.get(Stores.proposals, proposalId);
if (!proposal) {
throw Error(`could not get proposal ${proposalId}`);
}
if (proposal.proposalStatus === ProposalStatus.REPURCHASE) {
const existingProposalId = proposal.repurchaseProposalId;
if (!existingProposalId) {
throw Error("invalid proposal state");
}
console.log("using existing purchase for same product");
proposal = await ws.db.get(Stores.proposals, existingProposalId);
if (!proposal) {
throw Error("existing proposal is in wrong state");
}
}
const d = proposal.download;
if (!d) {
console.error("bad proposal", proposal);
throw Error("proposal is in invalid state");
}
const contractTerms = d.contractTerms;
const merchantSig = d.merchantSig;
if (!contractTerms || !merchantSig) {
throw Error("BUG: proposal is in invalid state");
}
proposalId = proposal.proposalId;
// First check if we already payed for it.
const purchase = await ws.db.get(Stores.purchases, proposalId);
if (!purchase) {
const paymentAmount = Amounts.parseOrThrow(contractTerms.amount);
let wireFeeLimit;
if (contractTerms.max_wire_fee) {
wireFeeLimit = Amounts.parseOrThrow(contractTerms.max_wire_fee);
} else {
wireFeeLimit = Amounts.getZero(paymentAmount.currency);
}
// If not already payed, check if we could pay for it.
const res = await getCoinsForPayment(ws, {
allowedAuditors: contractTerms.auditors,
allowedExchanges: contractTerms.exchanges,
depositFeeLimit: Amounts.parseOrThrow(contractTerms.max_fee),
paymentAmount,
wireFeeAmortization: contractTerms.wire_fee_amortization || 1,
wireFeeLimit,
wireFeeTime: extractTalerStampOrThrow(contractTerms.timestamp),
wireMethod: contractTerms.wire_method,
});
if (!res) {
console.log("not confirming payment, insufficient coins");
return {
status: "insufficient-balance",
contractTerms: contractTerms,
proposalId: proposal.proposalId,
};
}
// Only create speculative signature if we don't already have one for this proposal
if (
!ws.speculativePayData ||
(ws.speculativePayData &&
ws.speculativePayData.orderDownloadId !== proposalId)
) {
const { exchangeUrl, cds, totalAmount } = res;
const payCoinInfo = await ws.cryptoApi.signDeposit(
contractTerms,
cds,
totalAmount,
);
ws.speculativePayData = {
exchangeUrl,
payCoinInfo,
proposal,
orderDownloadId: proposalId,
};
logger.trace("created speculative pay data for payment");
}
return {
status: "payment-possible",
contractTerms: contractTerms,
proposalId: proposal.proposalId,
totalFees: res.totalFees,
};
}
if (uriResult.sessionId) {
await submitPay(ws, proposalId);
}
return {
status: "paid",
contractTerms: purchase.contractTerms,
nextUrl: getNextUrl(purchase.contractTerms),
};
}
/**
* Get the speculative pay data, but only if coins have not changed in between.
*/
async function getSpeculativePayData(
ws: InternalWalletState,
proposalId: string,
): Promise {
const sp = ws.speculativePayData;
if (!sp) {
return;
}
if (sp.orderDownloadId !== proposalId) {
return;
}
const coinKeys = sp.payCoinInfo.updatedCoins.map(x => x.coinPub);
const coins: CoinRecord[] = [];
for (let coinKey of coinKeys) {
const cc = await ws.db.get(Stores.coins, coinKey);
if (cc) {
coins.push(cc);
}
}
for (let i = 0; i < coins.length; i++) {
const specCoin = sp.payCoinInfo.originalCoins[i];
const currentCoin = coins[i];
// Coin does not exist anymore!
if (!currentCoin) {
return;
}
if (Amounts.cmp(specCoin.currentAmount, currentCoin.currentAmount) !== 0) {
return;
}
}
return sp;
}
/**
* Add a contract to the wallet and sign coins, and send them.
*/
export async function confirmPay(
ws: InternalWalletState,
proposalId: string,
sessionIdOverride: string | undefined,
): Promise {
logger.trace(
`executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`,
);
const proposal = await ws.db.get(Stores.proposals, proposalId);
if (!proposal) {
throw Error(`proposal with id ${proposalId} not found`);
}
const d = proposal.download;
if (!d) {
throw Error("proposal is in invalid state");
}
let purchase = await ws.db.get(Stores.purchases, d.contractTermsHash);
if (purchase) {
if (
sessionIdOverride !== undefined &&
sessionIdOverride != purchase.lastSessionId
) {
logger.trace(`changing session ID to ${sessionIdOverride}`);
await ws.db.mutate(Stores.purchases, purchase.proposalId, x => {
x.lastSessionId = sessionIdOverride;
x.paymentSubmitPending = true;
return x;
});
}
logger.trace("confirmPay: submitting payment for existing purchase");
return submitPay(ws, proposalId);
}
logger.trace("confirmPay: purchase record does not exist yet");
const contractAmount = Amounts.parseOrThrow(d.contractTerms.amount);
let wireFeeLimit;
if (!d.contractTerms.max_wire_fee) {
wireFeeLimit = Amounts.getZero(contractAmount.currency);
} else {
wireFeeLimit = Amounts.parseOrThrow(d.contractTerms.max_wire_fee);
}
const res = await getCoinsForPayment(ws, {
allowedAuditors: d.contractTerms.auditors,
allowedExchanges: d.contractTerms.exchanges,
depositFeeLimit: Amounts.parseOrThrow(d.contractTerms.max_fee),
paymentAmount: Amounts.parseOrThrow(d.contractTerms.amount),
wireFeeAmortization: d.contractTerms.wire_fee_amortization || 1,
wireFeeLimit,
wireFeeTime: extractTalerStampOrThrow(d.contractTerms.timestamp),
wireMethod: d.contractTerms.wire_method,
});
logger.trace("coin selection result", res);
if (!res) {
// Should not happen, since checkPay should be called first
console.log("not confirming payment, insufficient coins");
throw Error("insufficient balance");
}
const sd = await getSpeculativePayData(ws, proposalId);
if (!sd) {
const { exchangeUrl, cds, totalAmount } = res;
const payCoinInfo = await ws.cryptoApi.signDeposit(
d.contractTerms,
cds,
totalAmount,
);
purchase = await recordConfirmPay(
ws,
proposal,
payCoinInfo,
exchangeUrl,
sessionIdOverride,
);
} else {
purchase = await recordConfirmPay(
ws,
sd.proposal,
sd.payCoinInfo,
sd.exchangeUrl,
sessionIdOverride,
);
}
logger.trace("confirmPay: submitting payment after creating purchase record");
return submitPay(ws, proposalId);
}
export async function getFullRefundFees(
ws: InternalWalletState,
refundPermissions: MerchantRefundPermission[],
): Promise {
if (refundPermissions.length === 0) {
throw Error("no refunds given");
}
const coin0 = await ws.db.get(
Stores.coins,
refundPermissions[0].coin_pub,
);
if (!coin0) {
throw Error("coin not found");
}
let feeAcc = Amounts.getZero(
Amounts.parseOrThrow(refundPermissions[0].refund_amount).currency,
);
const denoms = await ws.db.iterIndex(
Stores.denominations.exchangeBaseUrlIndex,
coin0.exchangeBaseUrl,
).toArray();
for (const rp of refundPermissions) {
const coin = await ws.db.get(Stores.coins, rp.coin_pub);
if (!coin) {
throw Error("coin not found");
}
const denom = await ws.db.get(Stores.denominations, [
coin0.exchangeBaseUrl,
coin.denomPub,
]);
if (!denom) {
throw Error(`denom not found (${coin.denomPub})`);
}
// FIXME: this assumes that the refund already happened.
// When it hasn't, the refresh cost is inaccurate. To fix this,
// we need introduce a flag to tell if a coin was refunded or
// refreshed normally (and what about incremental refunds?)
const refundAmount = Amounts.parseOrThrow(rp.refund_amount);
const refundFee = Amounts.parseOrThrow(rp.refund_fee);
const refreshCost = getTotalRefreshCost(
denoms,
denom,
Amounts.sub(refundAmount, refundFee).amount,
);
feeAcc = Amounts.add(feeAcc, refreshCost, refundFee).amount;
}
return feeAcc;
}
async function acceptRefundResponse(
ws: InternalWalletState,
proposalId: string,
refundResponse: MerchantRefundResponse,
): Promise {
const refundPermissions = refundResponse.refund_permissions;
let numNewRefunds = 0;
await ws.db.runWithWriteTransaction([Stores.purchases], async tx => {
const p = await tx.get(Stores.purchases, proposalId);
if (!p) {
console.error("purchase not found, not adding refunds");
return;
}
if (!p.refundStatusRequested) {
return;
}
for (const perm of refundPermissions) {
if (
!p.refundsPending[perm.merchant_sig] &&
!p.refundsDone[perm.merchant_sig]
) {
p.refundsPending[perm.merchant_sig] = perm;
numNewRefunds++;
}
}
// Are we done with querying yet, or do we need to do another round
// after a retry delay?
let queryDone = true;
if (numNewRefunds === 0) {
if (
p.autoRefundDeadline &&
p.autoRefundDeadline.t_ms > getTimestampNow().t_ms
) {
queryDone = false;
}
}
if (queryDone) {
p.lastRefundStatusTimestamp = getTimestampNow();
p.lastRefundStatusError = undefined;
p.refundStatusRetryInfo = initRetryInfo();
p.refundStatusRequested = false;
console.log("refund query done");
} else {
// No error, but we need to try again!
p.lastRefundStatusTimestamp = getTimestampNow();
p.refundStatusRetryInfo.retryCounter++;
updateRetryInfoTimeout(p.refundStatusRetryInfo);
p.lastRefundStatusError = undefined;
console.log("refund query not done");
}
if (numNewRefunds) {
p.lastRefundApplyError = undefined;
p.refundApplyRetryInfo = initRetryInfo();
}
await tx.put(Stores.purchases, p);
});
ws.notify({
type: NotificationType.RefundQueried,
});
if (numNewRefunds > 0) {
await processPurchaseApplyRefund(ws, proposalId);
}
}
async function startRefundQuery(
ws: InternalWalletState,
proposalId: string,
): Promise {
const success = await ws.db.runWithWriteTransaction(
[Stores.purchases],
async tx => {
const p = await tx.get(Stores.purchases, proposalId);
if (!p) {
console.log("no purchase found for refund URL");
return false;
}
p.refundStatusRequested = true;
p.lastRefundStatusError = undefined;
p.refundStatusRetryInfo = initRetryInfo();
await tx.put(Stores.purchases, p);
return true;
},
);
if (!success) {
return;
}
ws.notify({
type: NotificationType.RefundStarted,
});
await processPurchaseQueryRefund(ws, proposalId);
}
/**
* 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 {
const parseResult = parseRefundUri(talerRefundUri);
console.log("applying refund");
if (!parseResult) {
throw Error("invalid refund URI");
}
const purchase = await ws.db.getIndexed(
Stores.purchases.orderIdIndex,
[parseResult.merchantBaseUrl, parseResult.orderId],
);
if (!purchase) {
throw Error("no purchase for the taler://refund/ URI was found");
}
console.log("processing purchase for refund");
await startRefundQuery(ws, purchase.proposalId);
return purchase.contractTermsHash;
}
export async function processPurchasePay(
ws: InternalWalletState,
proposalId: string,
forceNow: boolean = false,
): Promise {
const onOpErr = (e: OperationError) =>
incrementPurchasePayRetry(ws, proposalId, e);
await guardOperationException(
() => processPurchasePayImpl(ws, proposalId, forceNow),
onOpErr,
);
}
async function resetPurchasePayRetry(
ws: InternalWalletState,
proposalId: string,
) {
await ws.db.mutate(Stores.purchases, proposalId, x => {
if (x.payRetryInfo.active) {
x.payRetryInfo = initRetryInfo();
}
return x;
});
}
async function processPurchasePayImpl(
ws: InternalWalletState,
proposalId: string,
forceNow: boolean,
): Promise {
if (forceNow) {
await resetPurchasePayRetry(ws, proposalId);
}
const purchase = await ws.db.get(Stores.purchases, proposalId);
if (!purchase) {
return;
}
if (!purchase.paymentSubmitPending) {
return;
}
logger.trace(`processing purchase pay ${proposalId}`);
await submitPay(ws, proposalId);
}
export async function processPurchaseQueryRefund(
ws: InternalWalletState,
proposalId: string,
forceNow: boolean = false,
): Promise {
const onOpErr = (e: OperationError) =>
incrementPurchaseQueryRefundRetry(ws, proposalId, e);
await guardOperationException(
() => processPurchaseQueryRefundImpl(ws, proposalId, forceNow),
onOpErr,
);
}
async function resetPurchaseQueryRefundRetry(
ws: InternalWalletState,
proposalId: string,
) {
await ws.db.mutate(Stores.purchases, proposalId, x => {
if (x.refundStatusRetryInfo.active) {
x.refundStatusRetryInfo = initRetryInfo();
}
return x;
});
}
async function processPurchaseQueryRefundImpl(
ws: InternalWalletState,
proposalId: string,
forceNow: boolean,
): Promise {
if (forceNow) {
await resetPurchaseQueryRefundRetry(ws, proposalId);
}
const purchase = await ws.db.get(Stores.purchases, proposalId);
if (!purchase) {
return;
}
if (!purchase.refundStatusRequested) {
return;
}
const refundUrlObj = new URL(
"refund",
purchase.contractTerms.merchant_base_url,
);
refundUrlObj.searchParams.set("order_id", purchase.contractTerms.order_id);
const refundUrl = refundUrlObj.href;
let resp;
try {
resp = await ws.http.get(refundUrl);
} catch (e) {
console.error("error downloading refund permission", e);
throw e;
}
if (resp.status !== 200) {
throw Error(`unexpected status code (${resp.status}) for /refund`);
}
const refundResponse = MerchantRefundResponse.checked(await resp.json());
await acceptRefundResponse(ws, proposalId, refundResponse);
}
export async function processPurchaseApplyRefund(
ws: InternalWalletState,
proposalId: string,
forceNow: boolean = false,
): Promise {
const onOpErr = (e: OperationError) =>
incrementPurchaseApplyRefundRetry(ws, proposalId, e);
await guardOperationException(
() => processPurchaseApplyRefundImpl(ws, proposalId, forceNow),
onOpErr,
);
}
async function resetPurchaseApplyRefundRetry(
ws: InternalWalletState,
proposalId: string,
) {
await ws.db.mutate(Stores.purchases, proposalId, x => {
if (x.refundApplyRetryInfo.active) {
x.refundApplyRetryInfo = initRetryInfo();
}
return x;
});
}
async function processPurchaseApplyRefundImpl(
ws: InternalWalletState,
proposalId: string,
forceNow: boolean,
): Promise {
if (forceNow) {
await resetPurchaseApplyRefundRetry(ws, proposalId);
}
const purchase = await ws.db.get(Stores.purchases, proposalId);
if (!purchase) {
console.error("not submitting refunds, payment not found:");
return;
}
const pendingKeys = Object.keys(purchase.refundsPending);
if (pendingKeys.length === 0) {
console.log("no pending refunds");
return;
}
for (const pk of pendingKeys) {
const perm = purchase.refundsPending[pk];
const req: RefundRequest = {
coin_pub: perm.coin_pub,
h_contract_terms: purchase.contractTermsHash,
merchant_pub: purchase.contractTerms.merchant_pub,
merchant_sig: perm.merchant_sig,
refund_amount: perm.refund_amount,
refund_fee: perm.refund_fee,
rtransaction_id: perm.rtransaction_id,
};
console.log("sending refund permission", perm);
// FIXME: not correct once we support multiple exchanges per payment
const exchangeUrl = purchase.payReq.coins[0].exchange_url;
const reqUrl = new URL("refund", exchangeUrl);
const resp = await ws.http.postJson(reqUrl.href, req);
console.log("sent refund permission");
if (resp.status !== 200) {
console.error("refund failed", resp);
continue;
}
let allRefundsProcessed = false;
await ws.db.runWithWriteTransaction(
[Stores.purchases, Stores.coins],
async tx => {
const p = await tx.get(Stores.purchases, proposalId);
if (!p) {
return;
}
if (p.refundsPending[pk]) {
p.refundsDone[pk] = p.refundsPending[pk];
delete p.refundsPending[pk];
}
if (Object.keys(p.refundsPending).length === 0) {
p.refundStatusRetryInfo = initRetryInfo();
p.lastRefundStatusError = undefined;
allRefundsProcessed = true;
}
await tx.put(Stores.purchases, p);
const c = await tx.get(Stores.coins, perm.coin_pub);
if (!c) {
console.warn("coin not found, can't apply refund");
return;
}
const refundAmount = Amounts.parseOrThrow(perm.refund_amount);
const refundFee = Amounts.parseOrThrow(perm.refund_fee);
c.status = CoinStatus.Dirty;
c.currentAmount = Amounts.add(c.currentAmount, refundAmount).amount;
c.currentAmount = Amounts.sub(c.currentAmount, refundFee).amount;
await tx.put(Stores.coins, c);
},
);
if (allRefundsProcessed) {
ws.notify({
type: NotificationType.RefundFinished,
});
}
await refresh(ws, perm.coin_pub);
}
ws.notify({
type: NotificationType.RefundsSubmitted,
proposalId,
});
}