fix and simplify coin selection
This commit is contained in:
parent
54f7999c63
commit
adebfab94e
@ -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,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
@ -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();
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user