aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorFlorian Dold <florian.dold@gmail.com>2019-12-25 19:11:20 +0100
committerFlorian Dold <florian.dold@gmail.com>2019-12-25 19:11:20 +0100
commitadebfab94e76ee5d34a4f22d15fc085daef9ae00 (patch)
tree2dd0f233661fc32d2e5c2ee83750b3616d421359 /src
parent54f7999c63292ca63f5f584c49bdef0b55627d71 (diff)
fix and simplify coin selection
Diffstat (limited to 'src')
-rw-r--r--src/crypto/workers/cryptoApi.ts23
-rw-r--r--src/crypto/workers/cryptoImplementation.ts103
-rw-r--r--src/headless/taler-wallet-cli.ts10
-rw-r--r--src/operations/pay.ts386
-rw-r--r--src/types/dbTypes.ts8
-rw-r--r--src/types/talerTypes.ts4
-rw-r--r--src/types/walletTypes.ts82
-rw-r--r--src/util/amounts.ts6
-rw-r--r--src/wallet-test.ts112
9 files changed, 364 insertions, 370 deletions
diff --git a/src/crypto/workers/cryptoApi.ts b/src/crypto/workers/cryptoApi.ts
index 1c54d286a..489d56f5c 100644
--- a/src/crypto/workers/cryptoApi.ts
+++ b/src/crypto/workers/cryptoApi.ts
@@ -35,14 +35,13 @@ import {
import { CryptoWorker } from "./cryptoWorker";
-import { ContractTerms, PaybackRequest } from "../../types/talerTypes";
+import { ContractTerms, PaybackRequest, CoinDepositPermission } from "../../types/talerTypes";
import {
BenchmarkResult,
- CoinWithDenom,
- PaySigInfo,
PlanchetCreationResult,
PlanchetCreationRequest,
+ DepositInfo,
} from "../../types/walletTypes";
import * as timer from "../../util/timer";
@@ -384,19 +383,13 @@ export class CryptoApi {
);
}
- signDeposit(
- contractTermsRaw: string,
- contractData: WalletContractData,
- cds: CoinWithDenom[],
- totalAmount: AmountJson,
- ): Promise<PaySigInfo> {
- return this.doRpc<PaySigInfo>(
- "signDeposit",
+ signDepositPermission(
+ depositInfo: DepositInfo
+ ): Promise<CoinDepositPermission> {
+ return this.doRpc<CoinDepositPermission>(
+ "signDepositPermission",
3,
- contractTermsRaw,
- contractData,
- cds,
- totalAmount,
+ depositInfo
);
}
diff --git a/src/crypto/workers/cryptoImplementation.ts b/src/crypto/workers/cryptoImplementation.ts
index 043711864..d3295e749 100644
--- a/src/crypto/workers/cryptoImplementation.ts
+++ b/src/crypto/workers/cryptoImplementation.ts
@@ -36,14 +36,12 @@ import {
WalletContractData,
} from "../../types/dbTypes";
-import { CoinPaySig, ContractTerms, PaybackRequest } from "../../types/talerTypes";
+import { CoinDepositPermission, ContractTerms, PaybackRequest } from "../../types/talerTypes";
import {
BenchmarkResult,
- CoinWithDenom,
- PaySigInfo,
PlanchetCreationResult,
PlanchetCreationRequest,
- CoinPayInfo,
+ DepositInfo,
} from "../../types/walletTypes";
import { canonicalJson } from "../../util/helpers";
import { AmountJson } from "../../util/amounts";
@@ -331,82 +329,29 @@ export class CryptoImplementation {
* Generate updated coins (to store in the database)
* and deposit permissions for each given coin.
*/
- signDeposit(
- contractTermsRaw: string,
- contractData: WalletContractData,
- cds: CoinWithDenom[],
- totalAmount: AmountJson,
- ): PaySigInfo {
- const ret: PaySigInfo = {
- coinInfo: [],
- };
-
- 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;
- }
+ signDepositPermission(depositInfo: DepositInfo): CoinDepositPermission {
+
+ const d = buildSigPS(SignaturePurpose.WALLET_COIN_DEPOSIT)
+ .put(decodeCrock(depositInfo.contractTermsHash))
+ .put(decodeCrock(depositInfo.wireInfoHash))
+ .put(timestampToBuffer(depositInfo.timestamp))
+ .put(timestampToBuffer(depositInfo.refundDeadline))
+ .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 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;
+ 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;
}
/**
diff --git a/src/headless/taler-wallet-cli.ts b/src/headless/taler-wallet-cli.ts
index 491f6f556..aad49932e 100644
--- a/src/headless/taler-wallet-cli.ts
+++ b/src/headless/taler-wallet-cli.ts
@@ -540,12 +540,18 @@ testCli
.requiredOption("summary", ["-s", "--summary"], clk.STRING, {
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 => {
const cmdArgs = args.genPayUri;
console.log("creating order");
const merchantBackend = new MerchantBackendConnection(
- "https://backend.test.taler.net/",
- "sandbox",
+ cmdArgs.merchant,
+ cmdArgs.merchantApiKey,
);
const orderResp = await merchantBackend.createOrder(
cmdArgs.amount,
diff --git a/src/operations/pay.ts b/src/operations/pay.ts
index c7920020e..8fed54aa4 100644
--- a/src/operations/pay.ts
+++ b/src/operations/pay.ts
@@ -26,9 +26,7 @@
*/
import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto";
import {
- CoinRecord,
CoinStatus,
- DenominationRecord,
initRetryInfo,
ProposalRecord,
ProposalStatus,
@@ -41,153 +39,213 @@ import {
} from "../types/dbTypes";
import { NotificationType } from "../types/notifications";
import {
- Auditor,
- ContractTerms,
- ExchangeHandle,
- MerchantRefundResponse,
PayReq,
- Proposal,
codecForMerchantRefundResponse,
codecForProposal,
codecForContractTerms,
+ CoinDepositPermission,
} from "../types/talerTypes";
import {
- CoinSelectionResult,
- CoinWithDenom,
ConfirmPayResult,
OperationError,
- PaySigInfo,
PreparePayResult,
RefreshReason,
} from "../types/walletTypes";
import * as Amounts from "../util/amounts";
import { AmountJson } from "../util/amounts";
-import { amountToPretty, canonicalJson, strcmp } from "../util/helpers";
import { Logger } from "../util/logging";
import { getOrderDownloadUrl, parsePayUri } from "../util/taleruri";
import { guardOperationException } from "./errors";
import { createRefreshGroup, getTotalRefreshCost } from "./refresh";
import { acceptRefundResponse } from "./refund";
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[];
- allowedExchanges: ExchangeHandle[];
- depositFeeLimit: AmountJson;
+/**
+ * Result of selecting coins, contains the exchange, and selected
+ * coins with their denomination.
+ */
+export interface PayCoinSelection {
+ /**
+ * Amount requested by the merchant.
+ */
paymentAmount: AmountJson;
- wireFeeAmortization: number;
- wireFeeLimit: AmountJson;
- wireFeeTime: Timestamp;
- wireMethod: string;
+
+ /**
+ * Public keys of the coins that were selected.
+ */
+ 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 {
- cds: CoinWithDenom[];
- totalFees: AmountJson;
+export interface AvailableCoinInfo {
+ coinPub: string;
+ denomPub: string;
+ availableAmount: AmountJson;
+ feeDeposit: AmountJson;
}
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
*/
export function selectPayCoins(
- denoms: DenominationRecord[],
- cds: CoinWithDenom[],
+ acis: AvailableCoinInfo[],
paymentAmount: AmountJson,
depositFeeLimit: AmountJson,
-): SelectPayCoinsResult | undefined {
- if (cds.length === 0) {
+): PayCoinSelection | undefined {
+ if (acis.length === 0) {
return undefined;
}
+ const coinPubs: string[] = [];
+ const coinContributions: AmountJson[] = [];
// Sort by ascending deposit fee and denomPub if deposit fee is the same
// (to guarantee deterministic results)
- cds.sort(
+ acis.sort(
(o1, o2) =>
- Amounts.cmp(o1.denom.feeDeposit, o2.denom.feeDeposit) ||
- strcmp(o1.denom.denomPub, o2.denom.denomPub),
+ Amounts.cmp(o1.feeDeposit, o2.feeDeposit) ||
+ strcmp(o1.denomPub, o2.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) {
+ const currency = paymentAmount.currency;
+ let totalFees = Amounts.getZero(currency);
+ let amountPayRemaining = paymentAmount;
+ let amountDepositFeeLimitRemaining = depositFeeLimit;
+ let customerWireFees = Amounts.getZero(currency);
+ 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;
}
- if (coin.status !== CoinStatus.Fresh) {
- continue;
+ if (amountPayRemaining.value === 0 && amountPayRemaining.fraction === 0) {
+ // 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(
+ aci.feeDeposit,
+ amountDepositFeeLimitRemaining,
+ ).amount;
+
+ if (Amounts.isZero(depositFeeSpend)) {
+ // Fees are still covered by the merchant.
+ amountDepositFeeLimitRemaining = Amounts.sub(
+ amountDepositFeeLimitRemaining,
+ aci.feeDeposit,
+ ).amount;
+ } else {
+ amountDepositFeeLimitRemaining = Amounts.getZero(currency);
}
- cdsResult.push({ coin, denom });
- accDepositFee = Amounts.add(denom.feeDeposit, accDepositFee).amount;
- let leftAmount = Amounts.sub(
- coin.currentAmount,
- Amounts.sub(paymentAmount, accAmount).amount,
+
+ let coinSpend: AmountJson;
+ const amountActualAvailable = Amounts.sub(
+ aci.availableAmount,
+ depositFeeSpend,
).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)
+ 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;
- 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 };
}
+
+ 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;
}
/**
- * Get exchanges and associated coins that are still spendable, but only
- * if the sum the coins' remaining value covers the payment amount and fees.
+ * Select coins from the wallet's database that can be used
+ * to pay for the given contract.
+ *
+ * If payment is impossible, undefined is returned.
*/
async function getCoinsForPayment(
ws: InternalWalletState,
- args: WalletContractData,
-): Promise<CoinSelectionResult | undefined> {
- const {
- allowedAuditors,
- allowedExchanges,
- maxDepositFee,
- amount,
- wireFeeAmortization,
- maxWireFee,
- timestamp,
- wireMethod,
- } = args;
-
- let remainingAmount = amount;
+ contractData: WalletContractData,
+): Promise<PayCoinSelection | undefined> {
+ let remainingAmount = contractData.amount;
const exchanges = await ws.db.iter(Stores.exchanges).toArray();
@@ -203,7 +261,7 @@ async function getCoinsForPayment(
}
// is the exchange explicitly allowed?
- for (const allowedExchange of allowedExchanges) {
+ for (const allowedExchange of contractData.allowedExchanges) {
if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) {
isOkay = true;
break;
@@ -212,7 +270,7 @@ async function getCoinsForPayment(
// is the exchange allowed because of one of its auditors?
if (!isOkay) {
- for (const allowedAuditor of allowedAuditors) {
+ for (const allowedAuditor of contractData.allowedAuditors) {
for (const auditor of exchangeDetails.auditors) {
if (auditor.auditor_pub === allowedAuditor.auditorPub) {
isOkay = true;
@@ -251,7 +309,7 @@ async function getCoinsForPayment(
throw Error("db inconsistent");
}
const currency = firstDenom.value.currency;
- const cds: CoinWithDenom[] = [];
+ const acis: AvailableCoinInfo[] = [];
for (const coin of coins) {
const denom = await ws.db.get(Stores.denominations, [
exchange.baseUrl,
@@ -272,36 +330,45 @@ async function getCoinsForPayment(
if (coin.status !== CoinStatus.Fresh) {
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 wireFee: AmountJson | undefined;
- for (const fee of exchangeFees.feesForType[wireMethod] || []) {
- if (fee.startStamp <= timestamp && fee.endStamp >= timestamp) {
+ for (const fee of exchangeFees.feesForType[contractData.wireMethod] || []) {
+ if (
+ fee.startStamp <= contractData.timestamp &&
+ fee.endStamp >= contractData.timestamp
+ ) {
wireFee = fee.wireFee;
break;
}
}
if (wireFee) {
- const amortizedWireFee = Amounts.divide(wireFee, wireFeeAmortization);
- if (Amounts.cmp(maxWireFee, amortizedWireFee) < 0) {
+ const amortizedWireFee = Amounts.divide(
+ wireFee,
+ contractData.wireFeeAmortization,
+ );
+ if (Amounts.cmp(contractData.maxWireFee, amortizedWireFee) < 0) {
totalFees = Amounts.add(amortizedWireFee, totalFees).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) {
- totalFees = Amounts.add(totalFees, res.totalFees).amount;
- return {
- cds: res.cds,
- exchangeUrl: exchange.baseUrl,
- totalAmount: remainingAmount,
- totalFees,
- };
+ return res;
}
}
return undefined;
@@ -314,7 +381,8 @@ async function getCoinsForPayment(
async function recordConfirmPay(
ws: InternalWalletState,
proposal: ProposalRecord,
- payCoinInfo: PaySigInfo,
+ coinSelection: PayCoinSelection,
+ coinDepositPermissions: CoinDepositPermission[],
sessionIdOverride: string | undefined,
): Promise<PurchaseRecord> {
const d = proposal.download;
@@ -329,7 +397,7 @@ async function recordConfirmPay(
}
logger.trace(`recording payment with session ID ${sessionId}`);
const payReq: PayReq = {
- coins: payCoinInfo.coinInfo.map(x => x.sig),
+ coins: coinDepositPermissions,
merchant_pub: d.contractData.merchantPub,
mode: "pay",
order_id: d.contractData.orderId,
@@ -373,15 +441,15 @@ async function recordConfirmPay(
await tx.put(Stores.proposals, p);
}
await tx.put(Stores.purchases, t);
- for (let coinInfo of payCoinInfo.coinInfo) {
- const coin = await tx.get(Stores.coins, coinInfo.coinPub);
+ for (let i = 0; i < coinSelection.coinPubs.length; i++) {
+ const coin = await tx.get(Stores.coins, coinSelection.coinPubs[i]);
if (!coin) {
throw Error("coin allocated for payment doesn't exist anymore");
}
coin.status = CoinStatus.Dormant;
const remaining = Amounts.sub(
coin.currentAmount,
- coinInfo.subtractedAmount,
+ coinSelection.coinContributions[i],
);
if (remaining.saturated) {
throw Error("not enough remaining balance on coin for payment");
@@ -389,9 +457,7 @@ async function recordConfirmPay(
coin.currentAmount = remaining.amount;
await tx.put(Stores.coins, coin);
}
- const refreshCoinPubs = payCoinInfo.coinInfo.map(x => ({
- coinPub: x.coinPub,
- }));
+ const refreshCoinPubs = coinSelection.coinPubs.map(x => ({ coinPub: x }));
await createRefreshGroup(tx, refreshCoinPubs, RefreshReason.Pay);
},
);
@@ -738,6 +804,7 @@ export async function submitPay(
const payUrl = new URL("pay", purchase.contractData.merchantBaseUrl).href;
try {
+ console.log("pay req", payReq);
resp = await ws.http.postJson(payUrl, payReq);
} catch (e) {
// Gives the user the option to retry / abort and refresh
@@ -745,6 +812,7 @@ export async function submitPay(
throw e;
}
if (resp.status !== 200) {
+ console.log(await resp.json());
throw Error(`unexpected status (${resp.status}) for /pay`);
}
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 {
status: "payment-possible",
contractTermsRaw: d.contractTermsRaw,
proposalId: proposal.proposalId,
- totalFees: res.totalFees,
+ totalFees,
};
}
@@ -957,17 +1028,42 @@ export async function confirmPay(
throw Error("insufficient balance");
}
- const { cds, totalAmount } = res;
- const payCoinInfo = await ws.cryptoApi.signDeposit(
- d.contractTermsRaw,
- d.contractData,
- cds,
- totalAmount,
- );
+ const depositPermissions: CoinDepositPermission[] = [];
+ for (let i = 0; i < res.coinPubs.length; i++) {
+ const coin = await ws.db.get(Stores.coins, res.coinPubs[i]);
+ if (!coin) {
+ throw Error("can't pay, allocated coin not found anymore");
+ }
+ 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(
ws,
proposal,
- payCoinInfo,
+ res,
+ depositPermissions,
sessionIdOverride,
);
@@ -1019,23 +1115,29 @@ async function processPurchasePayImpl(
await submitPay(ws, proposalId);
}
-export async function refuseProposal(ws: InternalWalletState, proposalId: string) {
- const success = await ws.db.runWithWriteTransaction([Stores.proposals], async (tx) => {
- const proposal = await tx.get(Stores.proposals, proposalId);
- if (!proposal) {
- logger.trace(`proposal ${proposalId} not found, won't refuse proposal`);
- return false ;
- }
- if (proposal.proposalStatus !== ProposalStatus.PROPOSED) {
- return false;
- }
- proposal.proposalStatus = ProposalStatus.REFUSED;
- await tx.put(Stores.proposals, proposal);
- return true;
- });
+export async function refuseProposal(
+ ws: InternalWalletState,
+ proposalId: string,
+) {
+ const success = await ws.db.runWithWriteTransaction(
+ [Stores.proposals],
+ async tx => {
+ const proposal = await tx.get(Stores.proposals, proposalId);
+ if (!proposal) {
+ logger.trace(`proposal ${proposalId} not found, won't refuse proposal`);
+ return false;
+ }
+ if (proposal.proposalStatus !== ProposalStatus.PROPOSED) {
+ return false;
+ }
+ proposal.proposalStatus = ProposalStatus.REFUSED;
+ await tx.put(Stores.proposals, proposal);
+ return true;
+ },
+ );
if (success) {
ws.notify({
type: NotificationType.Wildcard,
});
}
-} \ No newline at end of file
+}
diff --git a/src/types/dbTypes.ts b/src/types/dbTypes.ts
index 71fe99b6b..b8eca2ddf 100644
--- a/src/types/dbTypes.ts
+++ b/src/types/dbTypes.ts
@@ -26,7 +26,7 @@
import { AmountJson } from "../util/amounts";
import {
Auditor,
- CoinPaySig,
+ CoinDepositPermission,
ContractTerms,
Denomination,
MerchantRefundPermission,
@@ -1085,6 +1085,10 @@ export interface AllowedExchangeInfo {
exchangePub: string;
}
+/**
+ * Data extracted from the contract terms that is relevant for payment
+ * processing in the wallet.
+ */
export interface WalletContractData {
fulfillmentUrl: string;
contractTermsHash: string;
@@ -1230,7 +1234,7 @@ export interface ConfigRecord {
* Coin that we're depositing ourselves.
*/
export interface DepositCoin {
- coinPaySig: CoinPaySig;
+ coinPaySig: CoinDepositPermission;
/**
* Undefined if coin not deposited, otherwise signature
diff --git a/src/types/talerTypes.ts b/src/types/talerTypes.ts
index f8e2b1c64..f8e449000 100644
--- a/src/types/talerTypes.ts
+++ b/src/types/talerTypes.ts
@@ -211,7 +211,7 @@ export class RecoupConfirmation {
/**
* Deposit permission for a single coin.
*/
-export interface CoinPaySig {
+export interface CoinDepositPermission {
/**
* Signature by the coin.
*/
@@ -401,7 +401,7 @@ export interface PayReq {
/**
* Coins with signature.
*/
- coins: CoinPaySig[];
+ coins: CoinDepositPermission[];
/**
* The merchant public key, used to uniquely
diff --git a/src/types/walletTypes.ts b/src/types/walletTypes.ts
index 223ca4329..9887474c3 100644
--- a/src/types/walletTypes.ts
+++ b/src/types/walletTypes.ts
@@ -33,9 +33,14 @@ import {
ExchangeRecord,
ExchangeWireInfo,
} from "./dbTypes";
-import { CoinPaySig, ContractTerms } from "./talerTypes";
+import { CoinDepositPermission, ContractTerms } from "./talerTypes";
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.
@@ -187,32 +192,6 @@ export interface WalletBalanceEntry {
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.
*/
@@ -302,7 +281,6 @@ export interface ConfirmReserveRequest {
reservePub: string;
}
-
export const codecForConfirmReserveRequest = () =>
typecheckedCodec<ConfirmReserveRequest>(
makeCodecForObject<ConfirmReserveRequest>()
@@ -337,34 +315,6 @@ export class 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.
@@ -511,3 +461,21 @@ export interface CoinPublicKey {
export interface RefreshGroupId {
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;
+}
diff --git a/src/util/amounts.ts b/src/util/amounts.ts
index c85c4839a..8deeaeccc 100644
--- a/src/util/amounts.ts
+++ b/src/util/amounts.ts
@@ -184,7 +184,7 @@ export function sub(a: AmountJson, ...rest: AmountJson[]): Result {
* Compare two amounts. Returns 0 when equal, -1 when a < b
* 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) {
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;
}
+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.
*/
diff --git a/src/wallet-test.ts b/src/wallet-test.ts
index c937de3f5..a465db517 100644
--- a/src/wallet-test.ts
+++ b/src/wallet-test.ts
@@ -19,11 +19,9 @@ import test from "ava";
import * as dbTypes from "./types/dbTypes";
import * as types from "./types/walletTypes";
-import * as wallet from "./wallet";
-
import { AmountJson } 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 {
const amt = Amounts.parse(x);
@@ -33,125 +31,99 @@ function a(x: string): AmountJson {
return amt;
}
-function fakeCwd(
+
+function fakeAci(
current: string,
- value: string,
feeDeposit: string,
-): types.CoinWithDenom {
+): AvailableCoinInfo {
return {
- coin: {
- blindingKey: "(mock)",
- coinPriv: "(mock)",
- coinPub: "(mock)",
- 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),
- },
- };
+ availableAmount: a(current),
+ coinPub: "foobar",
+ denomPub: "foobar",
+ feeDeposit: a(feeDeposit),
+ }
+
}
test("coin selection 1", t => {
- const cds: types.CoinWithDenom[] = [
- fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.1"),
- fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.0"),
+ const acis: AvailableCoinInfo[] = [
+ fakeAci("EUR:1.0", "EUR:0.1"),
+ 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) {
t.fail();
return;
}
- t.true(res.cds.length === 2);
+ t.true(res.coinPubs.length === 2);
t.pass();
});
test("coin selection 2", t => {
- const cds: types.CoinWithDenom[] = [
- fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"),
- fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.0"),
+ const acis: AvailableCoinInfo[] = [
+ fakeAci("EUR:1.0", "EUR:0.5"),
+ fakeAci("EUR:1.0", "EUR:0.0"),
// 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) {
t.fail();
return;
}
- t.true(res.cds.length === 2);
+ t.true(res.coinPubs.length === 2);
t.pass();
});
test("coin selection 3", t => {
- const cds: types.CoinWithDenom[] = [
- fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"),
- fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"),
+ const acis: AvailableCoinInfo[] = [
+ fakeAci("EUR:1.0", "EUR:0.5"),
+ fakeAci("EUR:1.0", "EUR:0.5"),
// 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) {
t.fail();
return;
}
- t.true(res.cds.length === 2);
+ t.true(res.coinPubs.length === 2);
t.pass();
});
test("coin selection 4", t => {
- const cds: types.CoinWithDenom[] = [
- fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"),
- fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"),
- fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"),
+ const acis: AvailableCoinInfo[] = [
+ fakeAci("EUR:1.0", "EUR:0.5"),
+ fakeAci("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) {
t.fail();
return;
}
- t.true(res.cds.length === 3);
+ t.true(res.coinPubs.length === 3);
t.pass();
});
test("coin selection 5", t => {
- const cds: types.CoinWithDenom[] = [
- fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"),
- fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"),
- fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"),
+ const acis: AvailableCoinInfo[] = [
+ fakeAci("EUR:1.0", "EUR:0.5"),
+ fakeAci("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.pass();
});
test("coin selection 6", t => {
- const cds: types.CoinWithDenom[] = [
- fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"),
- fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"),
+ const acis: AvailableCoinInfo[] = [
+ fakeAci("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.pass();
});