fix and simplify coin selection

This commit is contained in:
Florian Dold 2019-12-25 19:11:20 +01:00
parent 54f7999c63
commit adebfab94e
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
9 changed files with 365 additions and 371 deletions

View File

@ -35,14 +35,13 @@ import {
import { CryptoWorker } from "./cryptoWorker"; import { CryptoWorker } from "./cryptoWorker";
import { ContractTerms, PaybackRequest } from "../../types/talerTypes"; import { ContractTerms, PaybackRequest, CoinDepositPermission } from "../../types/talerTypes";
import { import {
BenchmarkResult, BenchmarkResult,
CoinWithDenom,
PaySigInfo,
PlanchetCreationResult, PlanchetCreationResult,
PlanchetCreationRequest, PlanchetCreationRequest,
DepositInfo,
} from "../../types/walletTypes"; } from "../../types/walletTypes";
import * as timer from "../../util/timer"; import * as timer from "../../util/timer";
@ -384,19 +383,13 @@ export class CryptoApi {
); );
} }
signDeposit( signDepositPermission(
contractTermsRaw: string, depositInfo: DepositInfo
contractData: WalletContractData, ): Promise<CoinDepositPermission> {
cds: CoinWithDenom[], return this.doRpc<CoinDepositPermission>(
totalAmount: AmountJson, "signDepositPermission",
): Promise<PaySigInfo> {
return this.doRpc<PaySigInfo>(
"signDeposit",
3, 3,
contractTermsRaw, depositInfo
contractData,
cds,
totalAmount,
); );
} }

View File

@ -36,14 +36,12 @@ import {
WalletContractData, WalletContractData,
} from "../../types/dbTypes"; } from "../../types/dbTypes";
import { CoinPaySig, ContractTerms, PaybackRequest } from "../../types/talerTypes"; import { CoinDepositPermission, ContractTerms, PaybackRequest } from "../../types/talerTypes";
import { import {
BenchmarkResult, BenchmarkResult,
CoinWithDenom,
PaySigInfo,
PlanchetCreationResult, PlanchetCreationResult,
PlanchetCreationRequest, PlanchetCreationRequest,
CoinPayInfo, DepositInfo,
} from "../../types/walletTypes"; } from "../../types/walletTypes";
import { canonicalJson } from "../../util/helpers"; import { canonicalJson } from "../../util/helpers";
import { AmountJson } from "../../util/amounts"; import { AmountJson } from "../../util/amounts";
@ -331,82 +329,29 @@ export class CryptoImplementation {
* Generate updated coins (to store in the database) * Generate updated coins (to store in the database)
* and deposit permissions for each given coin. * and deposit permissions for each given coin.
*/ */
signDeposit( signDepositPermission(depositInfo: DepositInfo): CoinDepositPermission {
contractTermsRaw: string,
contractData: WalletContractData, const d = buildSigPS(SignaturePurpose.WALLET_COIN_DEPOSIT)
cds: CoinWithDenom[], .put(decodeCrock(depositInfo.contractTermsHash))
totalAmount: AmountJson, .put(decodeCrock(depositInfo.wireInfoHash))
): PaySigInfo { .put(timestampToBuffer(depositInfo.timestamp))
const ret: PaySigInfo = { .put(timestampToBuffer(depositInfo.refundDeadline))
coinInfo: [], .put(amountToBuffer(depositInfo.spendAmount))
.put(amountToBuffer(depositInfo.feeDeposit))
.put(decodeCrock(depositInfo.merchantPub))
.put(decodeCrock(depositInfo.coinPub))
.build();
const coinSig = eddsaSign(d, decodeCrock(depositInfo.coinPriv));
const s: CoinDepositPermission = {
coin_pub: depositInfo.coinPub,
coin_sig: encodeCrock(coinSig),
contribution: Amounts.toString(depositInfo.spendAmount),
denom_pub: depositInfo.denomPub,
exchange_url: depositInfo.exchangeBaseUrl,
ub_sig: depositInfo.denomSig,
}; };
return s;
const contractTermsHash = this.hashString(canonicalJson(JSON.parse(contractTermsRaw)));
const feeList: AmountJson[] = cds.map(x => x.denom.feeDeposit);
let fees = Amounts.add(Amounts.getZero(feeList[0].currency), ...feeList)
.amount;
// okay if saturates
fees = Amounts.sub(fees, contractData.maxDepositFee).amount;
const total = Amounts.add(fees, totalAmount).amount;
let amountSpent = Amounts.getZero(cds[0].coin.currentAmount.currency);
let amountRemaining = total;
for (const cd of cds) {
if (amountRemaining.value === 0 && amountRemaining.fraction === 0) {
break;
}
let coinSpend: AmountJson;
if (Amounts.cmp(amountRemaining, cd.coin.currentAmount) < 0) {
coinSpend = amountRemaining;
} else {
coinSpend = cd.coin.currentAmount;
}
amountSpent = Amounts.add(amountSpent, coinSpend).amount;
const feeDeposit = cd.denom.feeDeposit;
// Give the merchant at least the deposit fee, otherwise it'll reject
// the coin.
if (Amounts.cmp(coinSpend, feeDeposit) < 0) {
coinSpend = feeDeposit;
}
const newAmount = Amounts.sub(cd.coin.currentAmount, coinSpend).amount;
cd.coin.currentAmount = newAmount;
const d = buildSigPS(SignaturePurpose.WALLET_COIN_DEPOSIT)
.put(decodeCrock(contractTermsHash))
.put(decodeCrock(contractData.wireInfoHash))
.put(timestampToBuffer(contractData.timestamp))
.put(timestampToBuffer(contractData.refundDeadline))
.put(amountToBuffer(coinSpend))
.put(amountToBuffer(cd.denom.feeDeposit))
.put(decodeCrock(contractData.merchantPub))
.put(decodeCrock(cd.coin.coinPub))
.build();
const coinSig = eddsaSign(d, decodeCrock(cd.coin.coinPriv));
const s: CoinPaySig = {
coin_pub: cd.coin.coinPub,
coin_sig: encodeCrock(coinSig),
contribution: Amounts.toString(coinSpend),
denom_pub: cd.coin.denomPub,
exchange_url: cd.denom.exchangeBaseUrl,
ub_sig: cd.coin.denomSig,
};
const coinInfo: CoinPayInfo = {
sig: s,
coinPub: cd.coin.coinPub,
subtractedAmount: coinSpend,
};
ret.coinInfo.push(coinInfo);
}
return ret;
} }
/** /**

View File

@ -540,12 +540,18 @@ testCli
.requiredOption("summary", ["-s", "--summary"], clk.STRING, { .requiredOption("summary", ["-s", "--summary"], clk.STRING, {
default: "Test Payment", default: "Test Payment",
}) })
.requiredOption("merchant", ["-m", "--merchant"], clk.STRING, {
default: "https://backend.test.taler.net/",
})
.requiredOption("merchantApiKey", ["-k", "--merchant-api-key"], clk.STRING, {
default: "sandbox",
})
.action(async args => { .action(async args => {
const cmdArgs = args.genPayUri; const cmdArgs = args.genPayUri;
console.log("creating order"); console.log("creating order");
const merchantBackend = new MerchantBackendConnection( const merchantBackend = new MerchantBackendConnection(
"https://backend.test.taler.net/", cmdArgs.merchant,
"sandbox", cmdArgs.merchantApiKey,
); );
const orderResp = await merchantBackend.createOrder( const orderResp = await merchantBackend.createOrder(
cmdArgs.amount, cmdArgs.amount,

View File

@ -26,9 +26,7 @@
*/ */
import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto"; import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto";
import { import {
CoinRecord,
CoinStatus, CoinStatus,
DenominationRecord,
initRetryInfo, initRetryInfo,
ProposalRecord, ProposalRecord,
ProposalStatus, ProposalStatus,
@ -41,153 +39,213 @@ import {
} from "../types/dbTypes"; } from "../types/dbTypes";
import { NotificationType } from "../types/notifications"; import { NotificationType } from "../types/notifications";
import { import {
Auditor,
ContractTerms,
ExchangeHandle,
MerchantRefundResponse,
PayReq, PayReq,
Proposal,
codecForMerchantRefundResponse, codecForMerchantRefundResponse,
codecForProposal, codecForProposal,
codecForContractTerms, codecForContractTerms,
CoinDepositPermission,
} from "../types/talerTypes"; } from "../types/talerTypes";
import { import {
CoinSelectionResult,
CoinWithDenom,
ConfirmPayResult, ConfirmPayResult,
OperationError, OperationError,
PaySigInfo,
PreparePayResult, PreparePayResult,
RefreshReason, RefreshReason,
} from "../types/walletTypes"; } from "../types/walletTypes";
import * as Amounts from "../util/amounts"; import * as Amounts from "../util/amounts";
import { AmountJson } from "../util/amounts"; import { AmountJson } from "../util/amounts";
import { amountToPretty, canonicalJson, strcmp } from "../util/helpers";
import { Logger } from "../util/logging"; import { Logger } from "../util/logging";
import { getOrderDownloadUrl, parsePayUri } from "../util/taleruri"; import { getOrderDownloadUrl, parsePayUri } from "../util/taleruri";
import { guardOperationException } from "./errors"; import { guardOperationException } from "./errors";
import { createRefreshGroup, getTotalRefreshCost } from "./refresh"; import { createRefreshGroup, getTotalRefreshCost } from "./refresh";
import { acceptRefundResponse } from "./refund"; import { acceptRefundResponse } from "./refund";
import { InternalWalletState } from "./state"; import { InternalWalletState } from "./state";
import { Timestamp, getTimestampNow, timestampAddDuration } from "../util/time"; import { getTimestampNow, timestampAddDuration } from "../util/time";
import { strcmp, canonicalJson } from "../util/helpers";
interface CoinsForPaymentArgs { /**
allowedAuditors: Auditor[]; * Result of selecting coins, contains the exchange, and selected
allowedExchanges: ExchangeHandle[]; * coins with their denomination.
depositFeeLimit: AmountJson; */
export interface PayCoinSelection {
/**
* Amount requested by the merchant.
*/
paymentAmount: AmountJson; paymentAmount: AmountJson;
wireFeeAmortization: number;
wireFeeLimit: AmountJson; /**
wireFeeTime: Timestamp; * Public keys of the coins that were selected.
wireMethod: string; */
coinPubs: string[];
/**
* Amount that each coin contributes.
*/
coinContributions: AmountJson[];
/**
* How much of the wire fees is the customer paying?
*/
customerWireFees: AmountJson;
/**
* How much of the deposit fees is the customer paying?
*/
customerDepositFees: AmountJson;
} }
interface SelectPayCoinsResult { export interface AvailableCoinInfo {
cds: CoinWithDenom[]; coinPub: string;
totalFees: AmountJson; denomPub: string;
availableAmount: AmountJson;
feeDeposit: AmountJson;
} }
const logger = new Logger("pay.ts"); const logger = new Logger("pay.ts");
/** /**
* Select coins for a payment under the merchant's constraints. * Compute the total cost of a payment to the customer.
*/
export async function getTotalPaymentCost(
ws: InternalWalletState,
pcs: PayCoinSelection,
): Promise<AmountJson> {
const costs = [
pcs.paymentAmount,
pcs.customerDepositFees,
pcs.customerWireFees,
];
for (let i = 0; i < pcs.coinPubs.length; i++) {
const coin = await ws.db.get(Stores.coins, pcs.coinPubs[i]);
if (!coin) {
throw Error("can't calculate payment cost, coin not found");
}
const denom = await ws.db.get(Stores.denominations, [
coin.exchangeBaseUrl,
coin.denomPub,
]);
if (!denom) {
throw Error(
"can't calculate payment cost, denomination for coin not found",
);
}
const allDenoms = await ws.db
.iterIndex(
Stores.denominations.exchangeBaseUrlIndex,
coin.exchangeBaseUrl,
)
.toArray();
const amountLeft = Amounts.sub(denom.value, pcs.coinContributions[i])
.amount;
const refreshCost = getTotalRefreshCost(allDenoms, denom, amountLeft);
costs.push(refreshCost);
}
return Amounts.sum(costs).amount;
}
/**
* Given a list of available coins, select coins to spend under the merchant's
* constraints.
*
* This function is only exported for the sake of unit tests.
* *
* @param denoms all available denoms, used to compute refresh fees * @param denoms all available denoms, used to compute refresh fees
*/ */
export function selectPayCoins( export function selectPayCoins(
denoms: DenominationRecord[], acis: AvailableCoinInfo[],
cds: CoinWithDenom[],
paymentAmount: AmountJson, paymentAmount: AmountJson,
depositFeeLimit: AmountJson, depositFeeLimit: AmountJson,
): SelectPayCoinsResult | undefined { ): PayCoinSelection | undefined {
if (cds.length === 0) { if (acis.length === 0) {
return undefined; return undefined;
} }
const coinPubs: string[] = [];
const coinContributions: AmountJson[] = [];
// Sort by ascending deposit fee and denomPub if deposit fee is the same // Sort by ascending deposit fee and denomPub if deposit fee is the same
// (to guarantee deterministic results) // (to guarantee deterministic results)
cds.sort( acis.sort(
(o1, o2) => (o1, o2) =>
Amounts.cmp(o1.denom.feeDeposit, o2.denom.feeDeposit) || Amounts.cmp(o1.feeDeposit, o2.feeDeposit) ||
strcmp(o1.denom.denomPub, o2.denom.denomPub), strcmp(o1.denomPub, o2.denomPub),
); );
const currency = cds[0].denom.value.currency; const currency = paymentAmount.currency;
const cdsResult: CoinWithDenom[] = []; let totalFees = Amounts.getZero(currency);
let accDepositFee: AmountJson = Amounts.getZero(currency); let amountPayRemaining = paymentAmount;
let accAmount: AmountJson = Amounts.getZero(currency); let amountDepositFeeLimitRemaining = depositFeeLimit;
for (const { coin, denom } of cds) { let customerWireFees = Amounts.getZero(currency);
if (coin.suspended) { let customerDepositFees = Amounts.getZero(currency);
for (const aci of acis) {
// Don't use this coin if depositing it is more expensive than
// the amount it would give the merchant.
if (Amounts.cmp(aci.feeDeposit, aci.availableAmount) >= 0) {
continue; continue;
} }
if (coin.status !== CoinStatus.Fresh) { if (amountPayRemaining.value === 0 && amountPayRemaining.fraction === 0) {
continue; // We have spent enough!
break;
} }
if (Amounts.cmp(denom.feeDeposit, coin.currentAmount) >= 0) {
continue; // How much does the user spend on deposit fees for this coin?
} const depositFeeSpend = Amounts.sub(
cdsResult.push({ coin, denom }); aci.feeDeposit,
accDepositFee = Amounts.add(denom.feeDeposit, accDepositFee).amount; amountDepositFeeLimitRemaining,
let leftAmount = Amounts.sub(
coin.currentAmount,
Amounts.sub(paymentAmount, accAmount).amount,
).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", { if (Amounts.isZero(depositFeeSpend)) {
coversAmount, // Fees are still covered by the merchant.
isBelowFee, amountDepositFeeLimitRemaining = Amounts.sub(
accDepositFee, amountDepositFeeLimitRemaining,
accAmount, aci.feeDeposit,
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; ).amount;
return { cds: cdsResult, totalFees }; } else {
amountDepositFeeLimitRemaining = Amounts.getZero(currency);
} }
let coinSpend: AmountJson;
const amountActualAvailable = Amounts.sub(
aci.availableAmount,
depositFeeSpend,
).amount;
if (Amounts.cmp(amountActualAvailable, amountPayRemaining) > 0) {
// Partial spending
coinSpend = Amounts.add(amountPayRemaining, depositFeeSpend).amount;
amountPayRemaining = Amounts.getZero(currency);
} else {
// Spend the full remaining amount
coinSpend = aci.availableAmount;
amountPayRemaining = Amounts.add(amountPayRemaining, depositFeeSpend)
.amount;
amountPayRemaining = Amounts.sub(amountPayRemaining, aci.availableAmount)
.amount;
}
coinPubs.push(aci.coinPub);
coinContributions.push(coinSpend);
totalFees = Amounts.add(totalFees, depositFeeSpend).amount;
}
if (Amounts.isZero(amountPayRemaining)) {
return {
paymentAmount,
coinContributions,
coinPubs,
customerDepositFees,
customerWireFees,
};
} }
return undefined; return undefined;
} }
/** /**
* Get exchanges and associated coins that are still spendable, but only * Select coins from the wallet's database that can be used
* if the sum the coins' remaining value covers the payment amount and fees. * to pay for the given contract.
*
* If payment is impossible, undefined is returned.
*/ */
async function getCoinsForPayment( async function getCoinsForPayment(
ws: InternalWalletState, ws: InternalWalletState,
args: WalletContractData, contractData: WalletContractData,
): Promise<CoinSelectionResult | undefined> { ): Promise<PayCoinSelection | undefined> {
const { let remainingAmount = contractData.amount;
allowedAuditors,
allowedExchanges,
maxDepositFee,
amount,
wireFeeAmortization,
maxWireFee,
timestamp,
wireMethod,
} = args;
let remainingAmount = amount;
const exchanges = await ws.db.iter(Stores.exchanges).toArray(); const exchanges = await ws.db.iter(Stores.exchanges).toArray();
@ -203,7 +261,7 @@ async function getCoinsForPayment(
} }
// is the exchange explicitly allowed? // is the exchange explicitly allowed?
for (const allowedExchange of allowedExchanges) { for (const allowedExchange of contractData.allowedExchanges) {
if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) { if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) {
isOkay = true; isOkay = true;
break; break;
@ -212,7 +270,7 @@ async function getCoinsForPayment(
// is the exchange allowed because of one of its auditors? // is the exchange allowed because of one of its auditors?
if (!isOkay) { if (!isOkay) {
for (const allowedAuditor of allowedAuditors) { for (const allowedAuditor of contractData.allowedAuditors) {
for (const auditor of exchangeDetails.auditors) { for (const auditor of exchangeDetails.auditors) {
if (auditor.auditor_pub === allowedAuditor.auditorPub) { if (auditor.auditor_pub === allowedAuditor.auditorPub) {
isOkay = true; isOkay = true;
@ -251,7 +309,7 @@ async function getCoinsForPayment(
throw Error("db inconsistent"); throw Error("db inconsistent");
} }
const currency = firstDenom.value.currency; const currency = firstDenom.value.currency;
const cds: CoinWithDenom[] = []; const acis: AvailableCoinInfo[] = [];
for (const coin of coins) { for (const coin of coins) {
const denom = await ws.db.get(Stores.denominations, [ const denom = await ws.db.get(Stores.denominations, [
exchange.baseUrl, exchange.baseUrl,
@ -272,36 +330,45 @@ async function getCoinsForPayment(
if (coin.status !== CoinStatus.Fresh) { if (coin.status !== CoinStatus.Fresh) {
continue; continue;
} }
cds.push({ coin, denom }); acis.push({
availableAmount: coin.currentAmount,
coinPub: coin.coinPub,
denomPub: coin.denomPub,
feeDeposit: denom.feeDeposit,
});
} }
let totalFees = Amounts.getZero(currency); let totalFees = Amounts.getZero(currency);
let wireFee: AmountJson | undefined; let wireFee: AmountJson | undefined;
for (const fee of exchangeFees.feesForType[wireMethod] || []) { for (const fee of exchangeFees.feesForType[contractData.wireMethod] || []) {
if (fee.startStamp <= timestamp && fee.endStamp >= timestamp) { if (
fee.startStamp <= contractData.timestamp &&
fee.endStamp >= contractData.timestamp
) {
wireFee = fee.wireFee; wireFee = fee.wireFee;
break; break;
} }
} }
if (wireFee) { if (wireFee) {
const amortizedWireFee = Amounts.divide(wireFee, wireFeeAmortization); const amortizedWireFee = Amounts.divide(
if (Amounts.cmp(maxWireFee, amortizedWireFee) < 0) { wireFee,
contractData.wireFeeAmortization,
);
if (Amounts.cmp(contractData.maxWireFee, amortizedWireFee) < 0) {
totalFees = Amounts.add(amortizedWireFee, totalFees).amount; totalFees = Amounts.add(amortizedWireFee, totalFees).amount;
remainingAmount = Amounts.add(amortizedWireFee, remainingAmount).amount; remainingAmount = Amounts.add(amortizedWireFee, remainingAmount).amount;
} }
} }
const res = selectPayCoins(denoms, cds, remainingAmount, maxDepositFee); // Try if paying using this exchange works
const res = selectPayCoins(
acis,
remainingAmount,
contractData.maxDepositFee,
);
if (res) { if (res) {
totalFees = Amounts.add(totalFees, res.totalFees).amount; return res;
return {
cds: res.cds,
exchangeUrl: exchange.baseUrl,
totalAmount: remainingAmount,
totalFees,
};
} }
} }
return undefined; return undefined;
@ -314,7 +381,8 @@ async function getCoinsForPayment(
async function recordConfirmPay( async function recordConfirmPay(
ws: InternalWalletState, ws: InternalWalletState,
proposal: ProposalRecord, proposal: ProposalRecord,
payCoinInfo: PaySigInfo, coinSelection: PayCoinSelection,
coinDepositPermissions: CoinDepositPermission[],
sessionIdOverride: string | undefined, sessionIdOverride: string | undefined,
): Promise<PurchaseRecord> { ): Promise<PurchaseRecord> {
const d = proposal.download; const d = proposal.download;
@ -329,7 +397,7 @@ async function recordConfirmPay(
} }
logger.trace(`recording payment with session ID ${sessionId}`); logger.trace(`recording payment with session ID ${sessionId}`);
const payReq: PayReq = { const payReq: PayReq = {
coins: payCoinInfo.coinInfo.map(x => x.sig), coins: coinDepositPermissions,
merchant_pub: d.contractData.merchantPub, merchant_pub: d.contractData.merchantPub,
mode: "pay", mode: "pay",
order_id: d.contractData.orderId, order_id: d.contractData.orderId,
@ -373,15 +441,15 @@ async function recordConfirmPay(
await tx.put(Stores.proposals, p); await tx.put(Stores.proposals, p);
} }
await tx.put(Stores.purchases, t); await tx.put(Stores.purchases, t);
for (let coinInfo of payCoinInfo.coinInfo) { for (let i = 0; i < coinSelection.coinPubs.length; i++) {
const coin = await tx.get(Stores.coins, coinInfo.coinPub); const coin = await tx.get(Stores.coins, coinSelection.coinPubs[i]);
if (!coin) { if (!coin) {
throw Error("coin allocated for payment doesn't exist anymore"); throw Error("coin allocated for payment doesn't exist anymore");
} }
coin.status = CoinStatus.Dormant; coin.status = CoinStatus.Dormant;
const remaining = Amounts.sub( const remaining = Amounts.sub(
coin.currentAmount, coin.currentAmount,
coinInfo.subtractedAmount, coinSelection.coinContributions[i],
); );
if (remaining.saturated) { if (remaining.saturated) {
throw Error("not enough remaining balance on coin for payment"); throw Error("not enough remaining balance on coin for payment");
@ -389,9 +457,7 @@ async function recordConfirmPay(
coin.currentAmount = remaining.amount; coin.currentAmount = remaining.amount;
await tx.put(Stores.coins, coin); await tx.put(Stores.coins, coin);
} }
const refreshCoinPubs = payCoinInfo.coinInfo.map(x => ({ const refreshCoinPubs = coinSelection.coinPubs.map(x => ({ coinPub: x }));
coinPub: x.coinPub,
}));
await createRefreshGroup(tx, refreshCoinPubs, RefreshReason.Pay); await createRefreshGroup(tx, refreshCoinPubs, RefreshReason.Pay);
}, },
); );
@ -738,6 +804,7 @@ export async function submitPay(
const payUrl = new URL("pay", purchase.contractData.merchantBaseUrl).href; const payUrl = new URL("pay", purchase.contractData.merchantBaseUrl).href;
try { try {
console.log("pay req", payReq);
resp = await ws.http.postJson(payUrl, payReq); resp = await ws.http.postJson(payUrl, payReq);
} catch (e) { } catch (e) {
// Gives the user the option to retry / abort and refresh // Gives the user the option to retry / abort and refresh
@ -745,6 +812,7 @@ export async function submitPay(
throw e; throw e;
} }
if (resp.status !== 200) { if (resp.status !== 200) {
console.log(await resp.json());
throw Error(`unexpected status (${resp.status}) for /pay`); throw Error(`unexpected status (${resp.status}) for /pay`);
} }
const merchantResp = await resp.json(); const merchantResp = await resp.json();
@ -872,11 +940,14 @@ export async function preparePayForUri(
}; };
} }
const totalCost = await getTotalPaymentCost(ws, res);
const totalFees = Amounts.sub(totalCost, res.paymentAmount).amount;
return { return {
status: "payment-possible", status: "payment-possible",
contractTermsRaw: d.contractTermsRaw, contractTermsRaw: d.contractTermsRaw,
proposalId: proposal.proposalId, proposalId: proposal.proposalId,
totalFees: res.totalFees, totalFees,
}; };
} }
@ -957,17 +1028,42 @@ export async function confirmPay(
throw Error("insufficient balance"); throw Error("insufficient balance");
} }
const { cds, totalAmount } = res; const depositPermissions: CoinDepositPermission[] = [];
const payCoinInfo = await ws.cryptoApi.signDeposit( for (let i = 0; i < res.coinPubs.length; i++) {
d.contractTermsRaw, const coin = await ws.db.get(Stores.coins, res.coinPubs[i]);
d.contractData, if (!coin) {
cds, throw Error("can't pay, allocated coin not found anymore");
totalAmount, }
); const denom = await ws.db.get(Stores.denominations, [
coin.exchangeBaseUrl,
coin.denomPub,
]);
if (!denom) {
throw Error(
"can't pay, denomination of allocated coin not found anymore",
);
}
const dp = await ws.cryptoApi.signDepositPermission({
coinPriv: coin.coinPriv,
coinPub: coin.coinPub,
contractTermsHash: d.contractData.contractTermsHash,
denomPub: coin.denomPub,
denomSig: coin.denomSig,
exchangeBaseUrl: coin.exchangeBaseUrl,
feeDeposit: denom.feeDeposit,
merchantPub: d.contractData.merchantPub,
refundDeadline: d.contractData.refundDeadline,
spendAmount: res.coinContributions[i],
timestamp: d.contractData.timestamp,
wireInfoHash: d.contractData.wireInfoHash,
});
depositPermissions.push(dp);
}
purchase = await recordConfirmPay( purchase = await recordConfirmPay(
ws, ws,
proposal, proposal,
payCoinInfo, res,
depositPermissions,
sessionIdOverride, sessionIdOverride,
); );
@ -1019,23 +1115,29 @@ async function processPurchasePayImpl(
await submitPay(ws, proposalId); await submitPay(ws, proposalId);
} }
export async function refuseProposal(ws: InternalWalletState, proposalId: string) { export async function refuseProposal(
const success = await ws.db.runWithWriteTransaction([Stores.proposals], async (tx) => { ws: InternalWalletState,
const proposal = await tx.get(Stores.proposals, proposalId); proposalId: string,
if (!proposal) { ) {
logger.trace(`proposal ${proposalId} not found, won't refuse proposal`); const success = await ws.db.runWithWriteTransaction(
return false ; [Stores.proposals],
} async tx => {
if (proposal.proposalStatus !== ProposalStatus.PROPOSED) { const proposal = await tx.get(Stores.proposals, proposalId);
return false; if (!proposal) {
} logger.trace(`proposal ${proposalId} not found, won't refuse proposal`);
proposal.proposalStatus = ProposalStatus.REFUSED; return false;
await tx.put(Stores.proposals, proposal); }
return true; if (proposal.proposalStatus !== ProposalStatus.PROPOSED) {
}); return false;
}
proposal.proposalStatus = ProposalStatus.REFUSED;
await tx.put(Stores.proposals, proposal);
return true;
},
);
if (success) { if (success) {
ws.notify({ ws.notify({
type: NotificationType.Wildcard, type: NotificationType.Wildcard,
}); });
} }
} }

View File

@ -26,7 +26,7 @@
import { AmountJson } from "../util/amounts"; import { AmountJson } from "../util/amounts";
import { import {
Auditor, Auditor,
CoinPaySig, CoinDepositPermission,
ContractTerms, ContractTerms,
Denomination, Denomination,
MerchantRefundPermission, MerchantRefundPermission,
@ -1085,6 +1085,10 @@ export interface AllowedExchangeInfo {
exchangePub: string; exchangePub: string;
} }
/**
* Data extracted from the contract terms that is relevant for payment
* processing in the wallet.
*/
export interface WalletContractData { export interface WalletContractData {
fulfillmentUrl: string; fulfillmentUrl: string;
contractTermsHash: string; contractTermsHash: string;
@ -1230,7 +1234,7 @@ export interface ConfigRecord {
* Coin that we're depositing ourselves. * Coin that we're depositing ourselves.
*/ */
export interface DepositCoin { export interface DepositCoin {
coinPaySig: CoinPaySig; coinPaySig: CoinDepositPermission;
/** /**
* Undefined if coin not deposited, otherwise signature * Undefined if coin not deposited, otherwise signature

View File

@ -211,7 +211,7 @@ export class RecoupConfirmation {
/** /**
* Deposit permission for a single coin. * Deposit permission for a single coin.
*/ */
export interface CoinPaySig { export interface CoinDepositPermission {
/** /**
* Signature by the coin. * Signature by the coin.
*/ */
@ -401,7 +401,7 @@ export interface PayReq {
/** /**
* Coins with signature. * Coins with signature.
*/ */
coins: CoinPaySig[]; coins: CoinDepositPermission[];
/** /**
* The merchant public key, used to uniquely * The merchant public key, used to uniquely

View File

@ -33,9 +33,14 @@ import {
ExchangeRecord, ExchangeRecord,
ExchangeWireInfo, ExchangeWireInfo,
} from "./dbTypes"; } from "./dbTypes";
import { CoinPaySig, ContractTerms } from "./talerTypes"; import { CoinDepositPermission, ContractTerms } from "./talerTypes";
import { Timestamp } from "../util/time"; import { Timestamp } from "../util/time";
import { typecheckedCodec, makeCodecForObject, codecForString, makeCodecOptional } from "../util/codec"; import {
typecheckedCodec,
makeCodecForObject,
codecForString,
makeCodecOptional,
} from "../util/codec";
/** /**
* Response for the create reserve request to the wallet. * Response for the create reserve request to the wallet.
@ -187,32 +192,6 @@ export interface WalletBalanceEntry {
pendingIncomingDirty: AmountJson; pendingIncomingDirty: AmountJson;
} }
export interface CoinPayInfo {
/**
* Amount that will be subtracted from the coin when the payment is finalized.
*/
subtractedAmount: AmountJson;
/**
* Public key of the coin that is being spent.
*/
coinPub: string;
/**
* Signature together with the other information needed by the merchant,
* directly in the format expected by the merchant.
*/
sig: CoinPaySig;
}
/**
* Coins used for a payment, with signatures authorizing the payment and the
* coins with remaining value updated to accomodate for a payment.
*/
export interface PaySigInfo {
coinInfo: CoinPayInfo[];
}
/** /**
* For terseness. * For terseness.
*/ */
@ -302,7 +281,6 @@ export interface ConfirmReserveRequest {
reservePub: string; reservePub: string;
} }
export const codecForConfirmReserveRequest = () => export const codecForConfirmReserveRequest = () =>
typecheckedCodec<ConfirmReserveRequest>( typecheckedCodec<ConfirmReserveRequest>(
makeCodecForObject<ConfirmReserveRequest>() makeCodecForObject<ConfirmReserveRequest>()
@ -337,34 +315,6 @@ export class ReturnCoinsRequest {
static checked: (obj: any) => ReturnCoinsRequest; static checked: (obj: any) => ReturnCoinsRequest;
} }
/**
* Result of selecting coins, contains the exchange, and selected
* coins with their denomination.
*/
export interface CoinSelectionResult {
exchangeUrl: string;
cds: CoinWithDenom[];
totalFees: AmountJson;
/**
* Total amount, including wire fees payed by the customer.
*/
totalAmount: AmountJson;
}
/**
* Named tuple of coin and denomination.
*/
export interface CoinWithDenom {
/**
* A coin. Must have the same denomination public key as the associated
* denomination.
*/
coin: CoinRecord;
/**
* An associated denomination.
*/
denom: DenominationRecord;
}
/** /**
* Status of processing a tip. * Status of processing a tip.
@ -511,3 +461,21 @@ export interface CoinPublicKey {
export interface RefreshGroupId { export interface RefreshGroupId {
readonly refreshGroupId: string; readonly refreshGroupId: string;
} }
/**
* Private data required to make a deposit permission.
*/
export interface DepositInfo {
exchangeBaseUrl: string;
contractTermsHash: string;
coinPub: string;
coinPriv: string;
spendAmount: AmountJson;
timestamp: Timestamp;
refundDeadline: Timestamp;
merchantPub: string;
feeDeposit: AmountJson;
wireInfoHash: string;
denomPub: string;
denomSig: string;
}

View File

@ -184,7 +184,7 @@ export function sub(a: AmountJson, ...rest: AmountJson[]): Result {
* Compare two amounts. Returns 0 when equal, -1 when a < b * Compare two amounts. Returns 0 when equal, -1 when a < b
* and +1 when a > b. Throws when currencies don't match. * and +1 when a > b. Throws when currencies don't match.
*/ */
export function cmp(a: AmountJson, b: AmountJson): number { export function cmp(a: AmountJson, b: AmountJson): -1 | 0 | 1 {
if (a.currency !== b.currency) { if (a.currency !== b.currency) {
throw Error(`Mismatched currency: ${a.currency} and ${b.currency}`); throw Error(`Mismatched currency: ${a.currency} and ${b.currency}`);
} }
@ -244,6 +244,10 @@ export function isNonZero(a: AmountJson): boolean {
return a.value > 0 || a.fraction > 0; return a.value > 0 || a.fraction > 0;
} }
export function isZero(a: AmountJson): boolean {
return a.value === 0 && a.fraction === 0;
}
/** /**
* Parse an amount like 'EUR:20.5' for 20 Euros and 50 ct. * Parse an amount like 'EUR:20.5' for 20 Euros and 50 ct.
*/ */

View File

@ -19,11 +19,9 @@ import test from "ava";
import * as dbTypes from "./types/dbTypes"; import * as dbTypes from "./types/dbTypes";
import * as types from "./types/walletTypes"; import * as types from "./types/walletTypes";
import * as wallet from "./wallet";
import { AmountJson } from "./util/amounts"; import { AmountJson } from "./util/amounts";
import * as Amounts from "./util/amounts"; import * as Amounts from "./util/amounts";
import { selectPayCoins } from "./operations/pay"; import { selectPayCoins, AvailableCoinInfo } from "./operations/pay";
function a(x: string): AmountJson { function a(x: string): AmountJson {
const amt = Amounts.parse(x); const amt = Amounts.parse(x);
@ -33,125 +31,99 @@ function a(x: string): AmountJson {
return amt; return amt;
} }
function fakeCwd(
function fakeAci(
current: string, current: string,
value: string,
feeDeposit: string, feeDeposit: string,
): types.CoinWithDenom { ): AvailableCoinInfo {
return { return {
coin: { availableAmount: a(current),
blindingKey: "(mock)", coinPub: "foobar",
coinPriv: "(mock)", denomPub: "foobar",
coinPub: "(mock)", feeDeposit: a(feeDeposit),
currentAmount: a(current), }
denomPub: "(mock)",
denomPubHash: "(mock)",
denomSig: "(mock)",
exchangeBaseUrl: "(mock)",
reservePub: "(mock)",
coinIndex: -1,
withdrawSessionId: "",
status: dbTypes.CoinStatus.Fresh,
},
denom: {
denomPub: "(mock)",
denomPubHash: "(mock)",
exchangeBaseUrl: "(mock)",
feeDeposit: a(feeDeposit),
feeRefresh: a("EUR:0.0"),
feeRefund: a("EUR:0.0"),
feeWithdraw: a("EUR:0.0"),
isOffered: true,
masterSig: "(mock)",
stampExpireDeposit: { t_ms: 0 },
stampExpireLegal: { t_ms: 0 },
stampExpireWithdraw: { t_ms: 0 },
stampStart: { t_ms: 0 },
status: dbTypes.DenominationStatus.VerifiedGood,
value: a(value),
},
};
} }
test("coin selection 1", t => { test("coin selection 1", t => {
const cds: types.CoinWithDenom[] = [ const acis: AvailableCoinInfo[] = [
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.1"), fakeAci("EUR:1.0", "EUR:0.1"),
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.0"), fakeAci("EUR:1.0", "EUR:0.0"),
]; ];
const res = selectPayCoins([], cds, a("EUR:2.0"), a("EUR:0.1")); const res = selectPayCoins(acis, a("EUR:2.0"), a("EUR:0.1"));
if (!res) { if (!res) {
t.fail(); t.fail();
return; return;
} }
t.true(res.cds.length === 2); t.true(res.coinPubs.length === 2);
t.pass(); t.pass();
}); });
test("coin selection 2", t => { test("coin selection 2", t => {
const cds: types.CoinWithDenom[] = [ const acis: AvailableCoinInfo[] = [
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"), fakeAci("EUR:1.0", "EUR:0.5"),
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.0"), fakeAci("EUR:1.0", "EUR:0.0"),
// Merchant covers the fee, this one shouldn't be used // Merchant covers the fee, this one shouldn't be used
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.0"), fakeAci("EUR:1.0", "EUR:0.0"),
]; ];
const res = selectPayCoins([], cds, a("EUR:2.0"), a("EUR:0.5")); const res = selectPayCoins(acis, a("EUR:2.0"), a("EUR:0.5"));
if (!res) { if (!res) {
t.fail(); t.fail();
return; return;
} }
t.true(res.cds.length === 2); t.true(res.coinPubs.length === 2);
t.pass(); t.pass();
}); });
test("coin selection 3", t => { test("coin selection 3", t => {
const cds: types.CoinWithDenom[] = [ const acis: AvailableCoinInfo[] = [
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"), fakeAci("EUR:1.0", "EUR:0.5"),
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"), fakeAci("EUR:1.0", "EUR:0.5"),
// this coin should be selected instead of previous one with fee // this coin should be selected instead of previous one with fee
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.0"), fakeAci("EUR:1.0", "EUR:0.0"),
]; ];
const res = selectPayCoins([], cds, a("EUR:2.0"), a("EUR:0.5")); const res = selectPayCoins(acis, a("EUR:2.0"), a("EUR:0.5"));
if (!res) { if (!res) {
t.fail(); t.fail();
return; return;
} }
t.true(res.cds.length === 2); t.true(res.coinPubs.length === 2);
t.pass(); t.pass();
}); });
test("coin selection 4", t => { test("coin selection 4", t => {
const cds: types.CoinWithDenom[] = [ const acis: AvailableCoinInfo[] = [
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"), fakeAci("EUR:1.0", "EUR:0.5"),
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"), fakeAci("EUR:1.0", "EUR:0.5"),
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"), fakeAci("EUR:1.0", "EUR:0.5"),
]; ];
const res = selectPayCoins([], cds, a("EUR:2.0"), a("EUR:0.2")); const res = selectPayCoins(acis, a("EUR:2.0"), a("EUR:0.5"));
if (!res) { if (!res) {
t.fail(); t.fail();
return; return;
} }
t.true(res.cds.length === 3); t.true(res.coinPubs.length === 3);
t.pass(); t.pass();
}); });
test("coin selection 5", t => { test("coin selection 5", t => {
const cds: types.CoinWithDenom[] = [ const acis: AvailableCoinInfo[] = [
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"), fakeAci("EUR:1.0", "EUR:0.5"),
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"), fakeAci("EUR:1.0", "EUR:0.5"),
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"), fakeAci("EUR:1.0", "EUR:0.5"),
]; ];
const res = selectPayCoins([], cds, a("EUR:4.0"), a("EUR:0.2")); const res = selectPayCoins(acis, a("EUR:4.0"), a("EUR:0.2"));
t.true(!res); t.true(!res);
t.pass(); t.pass();
}); });
test("coin selection 6", t => { test("coin selection 6", t => {
const cds: types.CoinWithDenom[] = [ const acis: AvailableCoinInfo[] = [
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"), fakeAci("EUR:1.0", "EUR:0.5"),
fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"), fakeAci("EUR:1.0", "EUR:0.5"),
]; ];
const res = selectPayCoins([], cds, a("EUR:2.0"), a("EUR:0.2")); const res = selectPayCoins(acis, a("EUR:2.0"), a("EUR:0.2"));
t.true(!res); t.true(!res);
t.pass(); t.pass();
}); });