wallet-core: implement insufficient balance details

For now, only for merchant payments
This commit is contained in:
Florian Dold 2023-01-05 18:45:49 +01:00
parent 44aaa7a636
commit 92f1b5928c
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
12 changed files with 392 additions and 80 deletions

View File

@ -419,6 +419,7 @@ export const codecForPreparePayResultInsufficientBalance =
"status",
codecForConstString(PreparePayResultType.InsufficientBalance),
)
.property("balanceDetails", codecForPayMerchantInsufficientBalanceDetails())
.build("PreparePayResultInsufficientBalance");
export const codecForPreparePayResultAlreadyConfirmed =
@ -483,6 +484,7 @@ export interface PreparePayResultInsufficientBalance {
amountRaw: string;
noncePriv: string;
talerUri: string;
balanceDetails: PayMerchantInsufficientBalanceDetails;
}
export interface PreparePayResultAlreadyConfirmed {
@ -2090,32 +2092,32 @@ export interface PayMerchantInsufficientBalanceDetails {
/**
* Amount requested by the merchant.
*/
amountRequested: AmountJson;
amountRequested: AmountString;
/**
* Balance of type "available" (see balance.ts for definition).
*/
balanceAvailable: AmountJson;
balanceAvailable: AmountString;
/**
* Balance of type "material" (see balance.ts for definition).
*/
balanceMaterial: AmountJson;
balanceMaterial: AmountString;
/**
* Balance of type "age-acceptable" (see balance.ts for definition).
*/
balanceAgeAcceptable: AmountJson;
balanceAgeAcceptable: AmountString;
/**
* Balance of type "merchant-acceptable" (see balance.ts for definition).
*/
balanceMechantAcceptable: AmountJson;
balanceMerchantAcceptable: AmountString;
/**
* Balance of type "merchant-depositable" (see balance.ts for definition).
*/
balanceMechantDepositable: AmountJson;
balanceMerchantDepositable: AmountString;
/**
* If the payment would succeed without fees
@ -2126,5 +2128,17 @@ export interface PayMerchantInsufficientBalanceDetails {
* It is not possible to give an exact value here, since it depends
* on the coin selection for the amount that would be additionally withdrawn.
*/
feeGapEstimate: AmountJson;
}
feeGapEstimate: AmountString;
}
const codecForPayMerchantInsufficientBalanceDetails =
(): Codec<PayMerchantInsufficientBalanceDetails> =>
buildCodecForObject<PayMerchantInsufficientBalanceDetails>()
.property("amountRequested", codecForAmountString())
.property("balanceAgeAcceptable", codecForAmountString())
.property("balanceAvailable", codecForAmountString())
.property("balanceMaterial", codecForAmountString())
.property("balanceMerchantAcceptable", codecForAmountString())
.property("balanceMerchantDepositable", codecForAmountString())
.property("feeGapEstimate", codecForAmountString())
.build("PayMerchantInsufficientBalanceDetails");

View File

@ -835,6 +835,14 @@ export enum RefreshOperationStatus {
FinishedWithError = 51 /* DORMANT_START + 1 */,
}
/**
* Group of refresh operations. The refreshed coins do not
* have to belong to the same exchange, but must have the same
* currency.
*
* FIXME: Should include the currency as a top-level field,
* but we need to write a migration for that.
*/
export interface RefreshGroupRecord {
operationStatus: RefreshOperationStatus;
@ -847,6 +855,13 @@ export interface RefreshGroupRecord {
*/
refreshGroupId: string;
/**
* Currency of this refresh group.
*
* FIXME: Write a migration to add this to earlier DB versions.
*/
currency: string;
/**
* Reason why this refresh group has been created.
*/

View File

@ -86,6 +86,7 @@ export interface RefreshOperations {
refreshGroups: typeof WalletStoresV1.refreshGroups;
coinAvailability: typeof WalletStoresV1.coinAvailability;
}>,
currency: string,
oldCoinPubs: CoinRefreshRequest[],
reason: RefreshReason,
): Promise<RefreshGroupId>;

View File

@ -778,6 +778,7 @@ export async function importBackup(
timestampFinished: backupRefreshGroup.timestamp_finish,
timestampCreated: backupRefreshGroup.timestamp_created,
refreshGroupId: backupRefreshGroup.refresh_group_id,
currency: Amounts.currencyOf(backupRefreshGroup.old_coins[0].input_amount),
reason,
lastErrorPerCoin: {},
oldCoinPubs: backupRefreshGroup.old_coins.map((x) => x.coin_pub),

View File

@ -16,15 +16,15 @@
/**
* Functions to compute the wallet's balance.
*
*
* There are multiple definition of the wallet's balance.
* We use the following terminology:
*
*
* - "available": Balance that the wallet believes will certainly be available
* for spending, modulo any failures of the exchange or double spending issues.
* This includes available coins *not* allocated to any
* spending/refresh/... operation. Pending withdrawals are *not* counted
* towards this balance, because they are not certain to succeed.
* towards this balance, because they are not certain to succeed.
* Pending refreshes *are* counted towards this balance.
* This balance type is nice to show to the user, because it does not
* temporarily decrease after payment when we are waiting for refreshes
@ -38,12 +38,11 @@
*
* - "merchant-acceptable": Subset of the material balance that can be spent with a particular
* merchant (restricted via min age, exchange, auditor, wire_method).
*
*
* - "merchant-depositable": Subset of the merchant-acceptable balance that the merchant
* can accept via their supported wire methods.
*/
/**
* Imports.
*/
@ -52,10 +51,16 @@ import {
BalancesResponse,
Amounts,
Logger,
AuditorHandle,
ExchangeHandle,
canonicalizeBaseUrl,
parsePaytoUri,
} from "@gnu-taler/taler-util";
import { WalletStoresV1 } from "../db.js";
import { AllowedAuditorInfo, AllowedExchangeInfo, RefreshGroupRecord, WalletStoresV1 } from "../db.js";
import { GetReadOnlyAccess } from "../util/query.js";
import { InternalWalletState } from "../internal-wallet-state.js";
import { getExchangeDetails } from "./exchanges.js";
import { checkLogicInvariant } from "../util/invariants.js";
/**
* Logger.
@ -68,6 +73,30 @@ interface WalletBalance {
pendingOutgoing: AmountJson;
}
/**
* Compute the available amount that the wallet expects to get
* out of a refresh group.
*/
function computeRefreshGroupAvailableAmount(r: RefreshGroupRecord): AmountJson {
// Don't count finished refreshes, since the refresh already resulted
// in coins being added to the wallet.
let available = Amounts.zeroOfCurrency(r.currency);
if (r.timestampFinished) {
return available;
}
for (let i = 0; i < r.oldCoinPubs.length; i++) {
const session = r.refreshSessionPerCoin[i];
if (session) {
// We are always assuming the refresh will succeed, thus we
// report the output as available balance.
available = Amounts.add(available, session.amountRefreshOutput).amount;
} else {
available = Amounts.add(available, r.estimatedOutputPerCoin[i]).amount;
}
}
return available;
}
/**
* Get balance information.
*/
@ -110,33 +139,11 @@ export async function getBalancesInsideTransaction(
});
await tx.refreshGroups.iter().forEach((r) => {
// Don't count finished refreshes, since the refresh already resulted
// in coins being added to the wallet.
if (r.timestampFinished) {
return;
}
for (let i = 0; i < r.oldCoinPubs.length; i++) {
const session = r.refreshSessionPerCoin[i];
if (session) {
const currency = Amounts.parseOrThrow(
session.amountRefreshOutput,
).currency;
const b = initBalance(currency);
// We are always assuming the refresh will succeed, thus we
// report the output as available balance.
b.available = Amounts.add(
b.available,
session.amountRefreshOutput,
).amount;
} else {
const currency = Amounts.parseOrThrow(r.inputPerCoin[i]).currency;
const b = initBalance(currency);
b.available = Amounts.add(
b.available,
r.estimatedOutputPerCoin[i],
).amount;
}
}
const b = initBalance(r.currency);
b.available = Amounts.add(
b.available,
computeRefreshGroupAvailableAmount(r),
).amount;
});
await tx.withdrawalGroups.iter().forEach((wds) => {
@ -194,3 +201,217 @@ export async function getBalances(
return wbal;
}
/**
* Information about the balance for a particular payment to a particular
* merchant.
*/
export interface MerchantPaymentBalanceDetails {
balanceAvailable: AmountJson;
}
export interface MerchantPaymentRestrictionsForBalance {
currency: string;
minAge: number;
acceptedExchanges: AllowedExchangeInfo[];
acceptedAuditors: AllowedAuditorInfo[];
acceptedWireMethods: string[];
}
export interface AcceptableExchanges {
/**
* Exchanges accepted by the merchant, but wire method might not match.
*/
acceptableExchanges: string[];
/**
* Exchanges accepted by the merchant, including a matching
* wire method, i.e. the merchant can deposit coins there.
*/
depositableExchanges: string[];
}
/**
* Get all exchanges that are acceptable for a particular payment.
*/
export async function getAcceptableExchangeBaseUrls(
ws: InternalWalletState,
req: MerchantPaymentRestrictionsForBalance,
): Promise<AcceptableExchanges> {
const acceptableExchangeUrls = new Set<string>();
const depositableExchangeUrls = new Set<string>();
await ws.db
.mktx((x) => [x.exchanges, x.exchangeDetails, x.auditorTrust])
.runReadOnly(async (tx) => {
// FIXME: We should have a DB index to look up all exchanges
// for a particular auditor ...
const canonExchanges = new Set<string>();
const canonAuditors = new Set<string>();
for (const exchangeHandle of req.acceptedExchanges) {
const normUrl = canonicalizeBaseUrl(exchangeHandle.exchangeBaseUrl);
canonExchanges.add(normUrl);
}
for (const auditorHandle of req.acceptedAuditors) {
const normUrl = canonicalizeBaseUrl(auditorHandle.auditorBaseUrl);
canonAuditors.add(normUrl);
}
await tx.exchanges.iter().forEachAsync(async (exchange) => {
const dp = exchange.detailsPointer;
if (!dp) {
return;
}
const { currency, masterPublicKey } = dp;
const exchangeDetails = await tx.exchangeDetails.indexes.byPointer.get([
exchange.baseUrl,
currency,
masterPublicKey,
]);
if (!exchangeDetails) {
return;
}
let acceptable = false;
if (canonExchanges.has(exchange.baseUrl)) {
acceptableExchangeUrls.add(exchange.baseUrl);
acceptable = true;
}
for (const exchangeAuditor of exchangeDetails.auditors) {
if (canonAuditors.has(exchangeAuditor.auditor_url)) {
acceptableExchangeUrls.add(exchange.baseUrl);
acceptable = true;
break;
}
}
if (!acceptable) {
return;
}
// FIXME: Also consider exchange and auditor public key
// instead of just base URLs?
let wireMethodSupported = false;
for (const acc of exchangeDetails.wireInfo.accounts) {
const pp = parsePaytoUri(acc.payto_uri);
checkLogicInvariant(!!pp);
for (const wm of req.acceptedWireMethods) {
if (pp.targetType === wm) {
wireMethodSupported = true;
break;
}
if (wireMethodSupported) {
break;
}
}
}
acceptableExchangeUrls.add(exchange.baseUrl);
if (wireMethodSupported) {
depositableExchangeUrls.add(exchange.baseUrl);
}
});
});
return {
acceptableExchanges: [...acceptableExchangeUrls],
depositableExchanges: [...depositableExchangeUrls],
};
}
export interface MerchantPaymentBalanceDetails {
/**
* Balance of type "available" (see balance.ts for definition).
*/
balanceAvailable: AmountJson;
/**
* Balance of type "material" (see balance.ts for definition).
*/
balanceMaterial: AmountJson;
/**
* Balance of type "age-acceptable" (see balance.ts for definition).
*/
balanceAgeAcceptable: AmountJson;
/**
* Balance of type "merchant-acceptable" (see balance.ts for definition).
*/
balanceMerchantAcceptable: AmountJson;
/**
* Balance of type "merchant-depositable" (see balance.ts for definition).
*/
balanceMerchantDepositable: AmountJson;
}
export async function getMerchantPaymentBalanceDetails(
ws: InternalWalletState,
req: MerchantPaymentRestrictionsForBalance,
): Promise<MerchantPaymentBalanceDetails> {
const acceptability = await getAcceptableExchangeBaseUrls(ws, req);
const d: MerchantPaymentBalanceDetails = {
balanceAvailable: Amounts.zeroOfCurrency(req.currency),
balanceMaterial: Amounts.zeroOfCurrency(req.currency),
balanceAgeAcceptable: Amounts.zeroOfCurrency(req.currency),
balanceMerchantAcceptable: Amounts.zeroOfCurrency(req.currency),
balanceMerchantDepositable: Amounts.zeroOfCurrency(req.currency),
};
const wbal = await ws.db
.mktx((x) => [
x.coins,
x.coinAvailability,
x.refreshGroups,
x.purchases,
x.withdrawalGroups,
])
.runReadOnly(async (tx) => {
await tx.coinAvailability.iter().forEach((ca) => {
const singleCoinAmount: AmountJson = {
currency: ca.currency,
fraction: ca.amountFrac,
value: ca.amountVal,
};
const coinAmount: AmountJson = Amounts.mult(
singleCoinAmount,
ca.freshCoinCount,
).amount;
d.balanceAvailable = Amounts.add(d.balanceAvailable, coinAmount).amount;
d.balanceMaterial = Amounts.add(d.balanceMaterial, coinAmount).amount;
if (ca.maxAge === 0 || ca.maxAge > req.minAge) {
d.balanceAgeAcceptable = Amounts.add(
d.balanceAgeAcceptable,
coinAmount,
).amount;
if (acceptability.acceptableExchanges.includes(ca.exchangeBaseUrl)) {
d.balanceMerchantAcceptable = Amounts.add(
d.balanceMerchantAcceptable,
coinAmount,
).amount;
if (
acceptability.depositableExchanges.includes(ca.exchangeBaseUrl)
) {
d.balanceMerchantDepositable = Amounts.add(
d.balanceMerchantDepositable,
coinAmount,
).amount;
}
}
}
});
await tx.refreshGroups.iter().forEach((r) => {
d.balanceAvailable = Amounts.add(
d.balanceAvailable,
computeRefreshGroupAvailableAmount(r),
).amount;
});
});
return d;
}

View File

@ -175,9 +175,11 @@ export async function spendCoins(
await tx.coins.put(coin);
await tx.coinAvailability.put(coinAvailability);
}
await ws.refreshOps.createRefreshGroup(
ws,
tx,
Amounts.currencyOf(csi.contributions[0]),
refreshCoinPubs,
RefreshReason.PayMerchant,
);

View File

@ -268,7 +268,7 @@ export async function getFeeForDeposit(
prevPayCoins: [],
});
if (!payCoinSel) {
if (payCoinSel.type !== "success") {
throw Error("insufficient funds");
}
@ -276,7 +276,7 @@ export async function getFeeForDeposit(
ws,
p.targetType,
amount,
payCoinSel,
payCoinSel.coinSel,
);
}
@ -355,16 +355,16 @@ export async function prepareDepositGroup(
prevPayCoins: [],
});
if (!payCoinSel) {
if (payCoinSel.type !== "success") {
throw Error("insufficient funds");
}
const totalDepositCost = await getTotalPaymentCost(ws, payCoinSel);
const totalDepositCost = await getTotalPaymentCost(ws, payCoinSel.coinSel);
const effectiveDepositAmount = await getEffectiveDepositAmount(
ws,
p.targetType,
payCoinSel,
payCoinSel.coinSel,
);
return {
@ -452,18 +452,18 @@ export async function createDepositGroup(
prevPayCoins: [],
});
if (!payCoinSel) {
if (payCoinSel.type !== "success") {
throw Error("insufficient funds");
}
const totalDepositCost = await getTotalPaymentCost(ws, payCoinSel);
const totalDepositCost = await getTotalPaymentCost(ws, payCoinSel.coinSel);
const depositGroupId = encodeCrock(getRandomBytes(32));
const effectiveDepositAmount = await getEffectiveDepositAmount(
ws,
p.targetType,
payCoinSel,
payCoinSel.coinSel,
);
const depositGroup: DepositGroupRecord = {
@ -474,9 +474,9 @@ export async function createDepositGroup(
noncePub: noncePair.pub,
timestampCreated: AbsoluteTime.toTimestamp(now),
timestampFinished: undefined,
payCoinSelection: payCoinSel,
payCoinSelection: payCoinSel.coinSel,
payCoinSelectionUid: encodeCrock(getRandomBytes(32)),
depositedPerCoin: payCoinSel.coinPubs.map(() => false),
depositedPerCoin: payCoinSel.coinSel.coinPubs.map(() => false),
merchantPriv: merchantPair.priv,
merchantPub: merchantPair.pub,
totalPayCost: Amounts.stringify(totalDepositCost),
@ -500,8 +500,8 @@ export async function createDepositGroup(
.runReadWrite(async (tx) => {
await spendCoins(ws, tx, {
allocationId: `txn:deposit:${depositGroup.depositGroupId}`,
coinPubs: payCoinSel.coinPubs,
contributions: payCoinSel.coinContributions.map((x) =>
coinPubs: payCoinSel.coinSel.coinPubs,
contributions: payCoinSel.coinSel.coinContributions.map((x) =>
Amounts.parseOrThrow(x),
),
refreshReason: RefreshReason.PayDeposit,

View File

@ -73,6 +73,7 @@ import {
TransactionType,
URL,
constructPayUri,
PayMerchantInsufficientBalanceDetails,
} from "@gnu-taler/taler-util";
import { EddsaKeypair } from "../crypto/cryptoImplementation.js";
import {
@ -131,11 +132,12 @@ import {
import { getExchangeDetails } from "./exchanges.js";
import { createRefreshGroup, getTotalRefreshCost } from "./refresh.js";
import { GetReadOnlyAccess } from "../util/query.js";
import { getMerchantPaymentBalanceDetails } from "./balance.js";
/**
* Logger.
*/
const logger = new Logger("pay.ts");
const logger = new Logger("pay-merchant.ts");
/**
* Compute the total cost of a payment to the customer.
@ -817,7 +819,7 @@ async function handleInsufficientFunds(
requiredMinimumAge: contractData.minimumAge,
});
if (!res) {
if (res.type !== "success") {
logger.trace("insufficient funds for coin re-selection");
return;
}
@ -841,8 +843,7 @@ async function handleInsufficientFunds(
if (!payInfo) {
return;
}
payInfo.payCoinSelection = res;
payInfo.payCoinSelection = res;
payInfo.payCoinSelection = res.coinSel;
payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(32));
await tx.purchases.put(p);
await spendCoins(ws, tx, {
@ -905,6 +906,8 @@ export async function selectCandidates(
x.coinAvailability,
])
.runReadOnly(async (tx) => {
// FIXME: Use the existing helper (from balance.ts) to
// get acceptable exchanges.
const denoms: AvailableDenom[] = [];
const exchanges = await tx.exchanges.iter().toArray();
const wfPerExchange: Record<string, AmountJson> = {};
@ -1030,6 +1033,7 @@ export function selectGreedy(
// 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.value) > 0) {
tally.lastDepositFee = Amounts.parseOrThrow(aci.feeDeposit);
continue;
}
@ -1129,6 +1133,13 @@ export function selectForced(
return selectedDenom;
}
export type SelectPayCoinsResult =
| {
type: "failure";
insufficientBalanceDetails: PayMerchantInsufficientBalanceDetails;
}
| { type: "success"; coinSel: PayCoinSelection };
/**
* Given a list of candidate coins, select coins to spend under the merchant's
* constraints.
@ -1142,7 +1153,7 @@ export function selectForced(
export async function selectPayCoinsNew(
ws: InternalWalletState,
req: SelectPayCoinRequestNg,
): Promise<PayCoinSelection | undefined> {
): Promise<SelectPayCoinsResult> {
const {
contractTermsAmount,
depositFeeLimit,
@ -1168,6 +1179,7 @@ export async function selectPayCoinsNew(
customerDepositFees: Amounts.zeroOfCurrency(currency),
customerWireFees: Amounts.zeroOfCurrency(currency),
wireFeeCoveredForExchange: new Set(),
lastDepositFee: Amounts.zeroOfCurrency(currency),
};
const prevPayCoins = req.prevPayCoins ?? [];
@ -1207,7 +1219,44 @@ export async function selectPayCoinsNew(
}
if (!selectedDenom) {
return undefined;
const details = await getMerchantPaymentBalanceDetails(ws, {
acceptedAuditors: req.auditors,
acceptedExchanges: req.exchanges,
acceptedWireMethods: [req.wireMethod],
currency: Amounts.currencyOf(req.contractTermsAmount),
minAge: req.requiredMinimumAge ?? 0,
});
let feeGapEstimate: AmountJson;
if (
Amounts.cmp(
details.balanceMerchantDepositable,
req.contractTermsAmount,
) >= 0
) {
// FIXME: We can probably give a better estimate.
feeGapEstimate = Amounts.add(
tally.amountPayRemaining,
tally.lastDepositFee,
).amount;
} else {
feeGapEstimate = Amounts.zeroOfAmount(req.contractTermsAmount);
}
return {
type: "failure",
insufficientBalanceDetails: {
amountRequested: Amounts.stringify(req.contractTermsAmount),
balanceAgeAcceptable: Amounts.stringify(details.balanceAgeAcceptable),
balanceAvailable: Amounts.stringify(details.balanceAvailable),
balanceMaterial: Amounts.stringify(details.balanceMaterial),
balanceMerchantAcceptable: Amounts.stringify(
details.balanceMerchantAcceptable,
),
balanceMerchantDepositable: Amounts.stringify(
details.balanceMerchantDepositable,
),
feeGapEstimate: Amounts.stringify(feeGapEstimate),
},
};
}
const finalSel = selectedDenom;
@ -1244,11 +1293,14 @@ export async function selectPayCoinsNew(
});
return {
paymentAmount: Amounts.stringify(contractTermsAmount),
coinContributions: coinContributions.map((x) => Amounts.stringify(x)),
coinPubs,
customerDepositFees: Amounts.stringify(tally.customerDepositFees),
customerWireFees: Amounts.stringify(tally.customerWireFees),
type: "success",
coinSel: {
paymentAmount: Amounts.stringify(contractTermsAmount),
coinContributions: coinContributions.map((x) => Amounts.stringify(x)),
coinPubs,
customerDepositFees: Amounts.stringify(tally.customerDepositFees),
customerWireFees: Amounts.stringify(tally.customerWireFees),
},
};
}
@ -1318,7 +1370,7 @@ export async function checkPaymentByProposalId(
wireMethod: contractData.wireMethod,
});
if (!res) {
if (res.type !== "success") {
logger.info("not allowing payment, insufficient coins");
return {
status: PreparePayResultType.InsufficientBalance,
@ -1327,10 +1379,11 @@ export async function checkPaymentByProposalId(
noncePriv: proposal.noncePriv,
amountRaw: Amounts.stringify(d.contractData.amount),
talerUri,
balanceDetails: res.insufficientBalanceDetails,
};
}
const totalCost = await getTotalPaymentCost(ws, res);
const totalCost = await getTotalPaymentCost(ws, res.coinSel);
logger.trace("costInfo", totalCost);
logger.trace("coinsForPayment", res);
@ -1340,7 +1393,7 @@ export async function checkPaymentByProposalId(
proposalId: proposal.proposalId,
noncePriv: proposal.noncePriv,
amountEffective: Amounts.stringify(totalCost),
amountRaw: Amounts.stringify(res.paymentAmount),
amountRaw: Amounts.stringify(res.coinSel.paymentAmount),
contractTermsHash: d.contractData.contractTermsHash,
talerUri,
};
@ -1666,9 +1719,9 @@ export async function confirmPay(
const contractData = d.contractData;
let maybeCoinSelection: PayCoinSelection | undefined = undefined;
let selectCoinsResult: SelectPayCoinsResult | undefined = undefined;
maybeCoinSelection = await selectPayCoinsNew(ws, {
selectCoinsResult = await selectPayCoinsNew(ws, {
auditors: contractData.allowedAuditors,
exchanges: contractData.allowedExchanges,
wireMethod: contractData.wireMethod,
@ -1681,9 +1734,9 @@ export async function confirmPay(
forcedSelection: forcedCoinSel,
});
logger.trace("coin selection result", maybeCoinSelection);
logger.trace("coin selection result", selectCoinsResult);
if (!maybeCoinSelection) {
if (selectCoinsResult.type === "failure") {
// Should not happen, since checkPay should be called first
// FIXME: Actually, this should be handled gracefully,
// and the status should be stored in the DB.
@ -1691,14 +1744,7 @@ export async function confirmPay(
throw Error("insufficient balance");
}
const coinSelection = maybeCoinSelection;
const depositPermissions = await generateDepositPermissions(
ws,
coinSelection,
d.contractData,
);
const coinSelection = selectCoinsResult.coinSel;
const payCostInfo = await getTotalPaymentCost(ws, coinSelection);
let sessionId: string | undefined;
@ -2373,6 +2419,7 @@ async function acceptRefunds(
await createRefreshGroup(
ws,
tx,
Amounts.currencyOf(refreshCoinsPubs[0].amount),
refreshCoinsPubs,
RefreshReason.Refund,
);

View File

@ -429,6 +429,7 @@ export async function processRecoupGroupHandler(
const refreshGroupId = await createRefreshGroup(
ws,
tx,
Amounts.currencyOf(rg2.scheduleRefreshCoins[0].amount),
rg2.scheduleRefreshCoins,
RefreshReason.Recoup,
);

View File

@ -850,6 +850,7 @@ export async function createRefreshGroup(
refreshGroups: typeof WalletStoresV1.refreshGroups;
coinAvailability: typeof WalletStoresV1.coinAvailability;
}>,
currency: string,
oldCoinPubs: CoinRefreshRequest[],
reason: RefreshReason,
): Promise<RefreshGroupId> {
@ -934,6 +935,7 @@ export async function createRefreshGroup(
const refreshGroup: RefreshGroupRecord = {
operationStatus: RefreshOperationStatus.Pending,
currency,
timestampFinished: undefined,
statusPerCoin: oldCoinPubs.map(() => RefreshCoinStatus.Pending),
oldCoinPubs: oldCoinPubs.map((x) => x.coinPub),
@ -1018,7 +1020,7 @@ export async function autoRefresh(
])
.runReadWrite(async (tx) => {
const exchange = await tx.exchanges.get(exchangeBaseUrl);
if (!exchange) {
if (!exchange || !exchange.detailsPointer) {
return;
}
const coins = await tx.coins.indexes.byBaseUrl
@ -1059,6 +1061,7 @@ export async function autoRefresh(
const res = await createRefreshGroup(
ws,
tx,
exchange.detailsPointer?.currency,
refreshCoins,
RefreshReason.Scheduled,
);

View File

@ -117,6 +117,8 @@ export interface CoinSelectionTally {
customerWireFees: AmountJson;
wireFeeCoveredForExchange: Set<string>;
lastDepositFee: AmountJson;
}
/**
@ -188,5 +190,6 @@ export function tallyFees(
customerDepositFees,
customerWireFees,
wireFeeCoveredForExchange,
lastDepositFee: feeDeposit,
};
}

View File

@ -1178,6 +1178,9 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
}
case WalletApiOperation.ForceRefresh: {
const req = codecForForceRefreshRequest().decode(payload);
if (req.coinPubList.length == 0) {
throw Error("refusing to create empty refresh group");
}
const refreshGroupId = await ws.db
.mktx((x) => [
x.refreshGroups,
@ -1207,6 +1210,7 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
return await createRefreshGroup(
ws,
tx,
Amounts.currencyOf(coinPubs[0].amount),
coinPubs,
RefreshReason.Manual,
);