From adebfab94e76ee5d34a4f22d15fc085daef9ae00 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Wed, 25 Dec 2019 19:11:20 +0100 Subject: [PATCH] fix and simplify coin selection --- src/crypto/workers/cryptoApi.ts | 23 +- src/crypto/workers/cryptoImplementation.ts | 103 ++---- src/headless/taler-wallet-cli.ts | 10 +- src/operations/pay.ts | 388 +++++++++++++-------- src/types/dbTypes.ts | 8 +- src/types/talerTypes.ts | 4 +- src/types/walletTypes.ts | 82 ++--- src/util/amounts.ts | 6 +- src/wallet-test.ts | 112 +++--- 9 files changed, 365 insertions(+), 371 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 { - return this.doRpc( - "signDeposit", + signDepositPermission( + depositInfo: DepositInfo + ): Promise { + return this.doRpc( + "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: [], + 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 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, }; - - 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; + 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 { + 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; - } - cdsResult.push({ coin, denom }); - accDepositFee = Amounts.add(denom.feeDeposit, accDepositFee).amount; - let leftAmount = Amounts.sub( - coin.currentAmount, - Amounts.sub(paymentAmount, accAmount).amount, + + // How much does the user spend on deposit fees for this coin? + const depositFeeSpend = Amounts.sub( + aci.feeDeposit, + amountDepositFeeLimitRemaining, ).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), + if (Amounts.isZero(depositFeeSpend)) { + // Fees are still covered by the merchant. + amountDepositFeeLimitRemaining = Amounts.sub( + amountDepositFeeLimitRemaining, + aci.feeDeposit, ).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; } /** - * 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 { - const { - allowedAuditors, - allowedExchanges, - maxDepositFee, - amount, - wireFeeAmortization, - maxWireFee, - timestamp, - wireMethod, - } = args; - - let remainingAmount = amount; + contractData: WalletContractData, +): Promise { + 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 { 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( makeCodecForObject() @@ -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(); });