make planchet management during withdrawal O(n) instead of O(n^2)

This commit is contained in:
Florian Dold 2020-05-11 18:03:25 +05:30
parent 7e947ca2cd
commit 5d6192b0cd
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
13 changed files with 409 additions and 247 deletions

View File

@ -30,6 +30,7 @@ import {
RefreshSessionRecord, RefreshSessionRecord,
TipPlanchet, TipPlanchet,
WireFee, WireFee,
DenominationSelectionInfo,
} from "../../types/dbTypes"; } from "../../types/dbTypes";
import { CryptoWorker } from "./cryptoWorker"; import { CryptoWorker } from "./cryptoWorker";
@ -435,7 +436,7 @@ export class CryptoApi {
exchangeBaseUrl: string, exchangeBaseUrl: string,
kappa: number, kappa: number,
meltCoin: CoinRecord, meltCoin: CoinRecord,
newCoinDenoms: DenominationRecord[], newCoinDenoms: DenominationSelectionInfo,
meltFee: AmountJson, meltFee: AmountJson,
): Promise<RefreshSessionRecord> { ): Promise<RefreshSessionRecord> {
return this.doRpc<RefreshSessionRecord>( return this.doRpc<RefreshSessionRecord>(

View File

@ -34,6 +34,7 @@ import {
TipPlanchet, TipPlanchet,
WireFee, WireFee,
CoinSourceType, CoinSourceType,
DenominationSelectionInfo,
} from "../../types/dbTypes"; } from "../../types/dbTypes";
import { CoinDepositPermission, RecoupRequest } from "../../types/talerTypes"; import { CoinDepositPermission, RecoupRequest } from "../../types/talerTypes";
@ -359,14 +360,15 @@ export class CryptoImplementation {
exchangeBaseUrl: string, exchangeBaseUrl: string,
kappa: number, kappa: number,
meltCoin: CoinRecord, meltCoin: CoinRecord,
newCoinDenoms: DenominationRecord[], newCoinDenoms: DenominationSelectionInfo,
meltFee: AmountJson, meltFee: AmountJson,
): RefreshSessionRecord { ): RefreshSessionRecord {
let valueWithFee = Amounts.getZero(newCoinDenoms[0].value.currency); const currency = newCoinDenoms.selectedDenoms[0].denom.value.currency;
let valueWithFee = Amounts.getZero(currency);
for (const ncd of newCoinDenoms) { for (const ncd of newCoinDenoms.selectedDenoms) {
valueWithFee = Amounts.add(valueWithFee, ncd.value, ncd.feeWithdraw) const t = Amounts.add(ncd.denom.value, ncd.denom.feeWithdraw).amount;
.amount; valueWithFee = Amounts.add(valueWithFee, Amounts.mult(t, ncd.count).amount).amount;
} }
// melt fee // melt fee
@ -386,9 +388,11 @@ export class CryptoImplementation {
transferPubs.push(encodeCrock(transferKeyPair.ecdhePub)); transferPubs.push(encodeCrock(transferKeyPair.ecdhePub));
} }
for (const denom of newCoinDenoms) { for (const denomSel of newCoinDenoms.selectedDenoms) {
const r = decodeCrock(denom.denomPub); for (let i = 0; i < denomSel.count; i++) {
sessionHc.update(r); const r = decodeCrock(denomSel.denom.denomPub);
sessionHc.update(r);
}
} }
sessionHc.update(decodeCrock(meltCoin.coinPub)); sessionHc.update(decodeCrock(meltCoin.coinPub));
@ -396,27 +400,29 @@ export class CryptoImplementation {
for (let i = 0; i < kappa; i++) { for (let i = 0; i < kappa; i++) {
const planchets: RefreshPlanchetRecord[] = []; const planchets: RefreshPlanchetRecord[] = [];
for (let j = 0; j < newCoinDenoms.length; j++) { for (let j = 0; j < newCoinDenoms.selectedDenoms.length; j++) {
const transferPriv = decodeCrock(transferPrivs[i]); const denomSel = newCoinDenoms.selectedDenoms[j];
const oldCoinPub = decodeCrock(meltCoin.coinPub); for (let k = 0; k < denomSel.count; k++) {
const transferSecret = keyExchangeEcdheEddsa(transferPriv, oldCoinPub); const coinNumber = planchets.length;
const transferPriv = decodeCrock(transferPrivs[i]);
const fresh = setupRefreshPlanchet(transferSecret, j); const oldCoinPub = decodeCrock(meltCoin.coinPub);
const transferSecret = keyExchangeEcdheEddsa(transferPriv, oldCoinPub);
const coinPriv = fresh.coinPriv; const fresh = setupRefreshPlanchet(transferSecret, coinNumber);
const coinPub = fresh.coinPub; const coinPriv = fresh.coinPriv;
const blindingFactor = fresh.bks; const coinPub = fresh.coinPub;
const pubHash = hash(coinPub); const blindingFactor = fresh.bks;
const denomPub = decodeCrock(newCoinDenoms[j].denomPub); const pubHash = hash(coinPub);
const ev = rsaBlind(pubHash, blindingFactor, denomPub); const denomPub = decodeCrock(denomSel.denom.denomPub);
const planchet: RefreshPlanchetRecord = { const ev = rsaBlind(pubHash, blindingFactor, denomPub);
blindingKey: encodeCrock(blindingFactor), const planchet: RefreshPlanchetRecord = {
coinEv: encodeCrock(ev), blindingKey: encodeCrock(blindingFactor),
privateKey: encodeCrock(coinPriv), coinEv: encodeCrock(ev),
publicKey: encodeCrock(coinPub), privateKey: encodeCrock(coinPriv),
}; publicKey: encodeCrock(coinPub),
planchets.push(planchet); };
sessionHc.update(ev); planchets.push(planchet);
sessionHc.update(ev);
}
} }
planchetsForGammas.push(planchets); planchetsForGammas.push(planchets);
} }
@ -432,9 +438,23 @@ export class CryptoImplementation {
const confirmSig = eddsaSign(confirmData, decodeCrock(meltCoin.coinPriv)); const confirmSig = eddsaSign(confirmData, decodeCrock(meltCoin.coinPriv));
let valueOutput = Amounts.getZero(newCoinDenoms[0].value.currency); let valueOutput = Amounts.getZero(currency);
for (const denom of newCoinDenoms) { for (const denomSel of newCoinDenoms.selectedDenoms) {
valueOutput = Amounts.add(valueOutput, denom.value).amount; const denom = denomSel.denom;
for (let i = 0; i < denomSel.count; i++) {
valueOutput = Amounts.add(valueOutput, denom.value).amount;
}
}
const newDenoms: string[] = [];
const newDenomHashes: string[] = [];
for (const denomSel of newCoinDenoms.selectedDenoms) {
const denom = denomSel.denom;
for (let i = 0; i < denomSel.count; i++) {
newDenoms.push(denom.denomPub);
newDenomHashes.push(denom.denomPubHash);
}
} }
const refreshSession: RefreshSessionRecord = { const refreshSession: RefreshSessionRecord = {
@ -442,8 +462,8 @@ export class CryptoImplementation {
exchangeBaseUrl, exchangeBaseUrl,
hash: encodeCrock(sessionHash), hash: encodeCrock(sessionHash),
meltCoinPub: meltCoin.coinPub, meltCoinPub: meltCoin.coinPub,
newDenomHashes: newCoinDenoms.map((d) => d.denomPubHash), newDenomHashes,
newDenoms: newCoinDenoms.map((d) => d.denomPub), newDenoms,
norevealIndex: undefined, norevealIndex: undefined,
planchetsForGammas: planchetsForGammas, planchetsForGammas: planchetsForGammas,
transferPrivs, transferPrivs,

View File

@ -106,18 +106,19 @@ export async function getBalancesInsideTransaction(
} }
}); });
await tx.iter(Stores.withdrawalGroups).forEach((wds) => { // FIXME: re-implement
let w = wds.totalCoinValue; // await tx.iter(Stores.withdrawalGroups).forEach((wds) => {
for (let i = 0; i < wds.planchets.length; i++) { // let w = wds.totalCoinValue;
if (wds.withdrawn[i]) { // for (let i = 0; i < wds.planchets.length; i++) {
const p = wds.planchets[i]; // if (wds.withdrawn[i]) {
if (p) { // const p = wds.planchets[i];
w = Amounts.sub(w, p.coinValue).amount; // if (p) {
} // w = Amounts.sub(w, p.coinValue).amount;
} // }
} // }
addTo(balanceStore, "pendingIncoming", w, wds.exchangeBaseUrl); // }
}); // addTo(balanceStore, "pendingIncoming", w, wds.exchangeBaseUrl);
// });
await tx.iter(Stores.purchases).forEach((t) => { await tx.iter(Stores.purchases).forEach((t) => {
if (t.timestampFirstSuccessfulPay) { if (t.timestampFirstSuccessfulPay) {

View File

@ -22,7 +22,6 @@ import {
Stores, Stores,
ProposalStatus, ProposalStatus,
ProposalRecord, ProposalRecord,
PlanchetRecord,
} from "../types/dbTypes"; } from "../types/dbTypes";
import { Amounts } from "../util/amounts"; import { Amounts } from "../util/amounts";
import { AmountJson } from "../util/amounts"; import { AmountJson } from "../util/amounts";
@ -34,7 +33,6 @@ import {
ReserveType, ReserveType,
ReserveCreationDetail, ReserveCreationDetail,
VerbosePayCoinDetails, VerbosePayCoinDetails,
VerboseWithdrawDetails,
VerboseRefreshDetails, VerboseRefreshDetails,
} from "../types/history"; } from "../types/history";
import { assertUnreachable } from "../util/assertUnreachable"; import { assertUnreachable } from "../util/assertUnreachable";
@ -177,6 +175,7 @@ export async function getHistory(
Stores.tips, Stores.tips,
Stores.withdrawalGroups, Stores.withdrawalGroups,
Stores.payEvents, Stores.payEvents,
Stores.planchets,
Stores.refundEvents, Stores.refundEvents,
Stores.reserveUpdatedEvents, Stores.reserveUpdatedEvents,
Stores.recoupGroups, Stores.recoupGroups,
@ -209,23 +208,6 @@ export async function getHistory(
tx.iter(Stores.withdrawalGroups).forEach((wsr) => { tx.iter(Stores.withdrawalGroups).forEach((wsr) => {
if (wsr.timestampFinish) { if (wsr.timestampFinish) {
const cs: PlanchetRecord[] = [];
wsr.planchets.forEach((x) => {
if (x) {
cs.push(x);
}
});
let verboseDetails: VerboseWithdrawDetails | undefined = undefined;
if (historyQuery?.extraDebug) {
verboseDetails = {
coins: cs.map((x) => ({
value: Amounts.stringify(x.coinValue),
denomPub: x.denomPub,
})),
};
}
history.push({ history.push({
type: HistoryEventType.Withdrawn, type: HistoryEventType.Withdrawn,
withdrawalGroupId: wsr.withdrawalGroupId, withdrawalGroupId: wsr.withdrawalGroupId,
@ -233,12 +215,12 @@ export async function getHistory(
HistoryEventType.Withdrawn, HistoryEventType.Withdrawn,
wsr.withdrawalGroupId, wsr.withdrawalGroupId,
), ),
amountWithdrawnEffective: Amounts.stringify(wsr.totalCoinValue), amountWithdrawnEffective: Amounts.stringify(wsr.denomsSel.totalCoinValue),
amountWithdrawnRaw: Amounts.stringify(wsr.rawWithdrawalAmount), amountWithdrawnRaw: Amounts.stringify(wsr.rawWithdrawalAmount),
exchangeBaseUrl: wsr.exchangeBaseUrl, exchangeBaseUrl: wsr.exchangeBaseUrl,
timestamp: wsr.timestampFinish, timestamp: wsr.timestampFinish,
withdrawalSource: wsr.source, withdrawalSource: wsr.source,
verboseDetails, verboseDetails: undefined,
}); });
} }
}); });

View File

@ -246,7 +246,7 @@ async function gatherWithdrawalPending(
resp: PendingOperationsResponse, resp: PendingOperationsResponse,
onlyDue = false, onlyDue = false,
): Promise<void> { ): Promise<void> {
await tx.iter(Stores.withdrawalGroups).forEach((wsr) => { await tx.iter(Stores.withdrawalGroups).forEachAsync(async (wsr) => {
if (wsr.timestampFinish) { if (wsr.timestampFinish) {
return; return;
} }
@ -258,11 +258,14 @@ async function gatherWithdrawalPending(
if (onlyDue && wsr.retryInfo.nextRetry.t_ms > now.t_ms) { if (onlyDue && wsr.retryInfo.nextRetry.t_ms > now.t_ms) {
return; return;
} }
const numCoinsWithdrawn = wsr.withdrawn.reduce( let numCoinsWithdrawn = 0;
(a, x) => a + (x ? 1 : 0), let numCoinsTotal = 0;
0, await tx.iterIndexed(Stores.planchets.byGroup, wsr.withdrawalGroupId).forEach((x) => {
); numCoinsTotal++;
const numCoinsTotal = wsr.withdrawn.length; if (x.withdrawalDone) {
numCoinsWithdrawn++;
}
});
resp.pendingOperations.push({ resp.pendingOperations.push({
type: PendingOperationType.Withdraw, type: PendingOperationType.Withdraw,
givesLifeness: true, givesLifeness: true,
@ -443,6 +446,7 @@ export async function getPendingOperations(
Stores.tips, Stores.tips,
Stores.purchases, Stores.purchases,
Stores.recoupGroups, Stores.recoupGroups,
Stores.planchets,
], ],
async (tx) => { async (tx) => {
const walletBalance = await getBalancesInsideTransaction(ws, tx); const walletBalance = await getBalancesInsideTransaction(ws, tx);

View File

@ -67,7 +67,9 @@ export function getTotalRefreshCost(
const withdrawDenoms = getWithdrawDenomList(withdrawAmount, denoms); const withdrawDenoms = getWithdrawDenomList(withdrawAmount, denoms);
const resultingAmount = Amounts.add( const resultingAmount = Amounts.add(
Amounts.getZero(withdrawAmount.currency), Amounts.getZero(withdrawAmount.currency),
...withdrawDenoms.map((d) => d.value), ...withdrawDenoms.selectedDenoms.map(
(d) => Amounts.mult(d.denom.value, d.count).amount,
),
).amount; ).amount;
const totalCost = Amounts.sub(amountLeft, resultingAmount).amount; const totalCost = Amounts.sub(amountLeft, resultingAmount).amount;
logger.trace( logger.trace(
@ -130,7 +132,7 @@ async function refreshCreateSession(
const newCoinDenoms = getWithdrawDenomList(availableAmount, availableDenoms); const newCoinDenoms = getWithdrawDenomList(availableAmount, availableDenoms);
if (newCoinDenoms.length === 0) { if (newCoinDenoms.selectedDenoms.length === 0) {
logger.trace( logger.trace(
`not refreshing, available amount ${amountToPretty( `not refreshing, available amount ${amountToPretty(
availableAmount, availableAmount,

View File

@ -33,7 +33,6 @@ import {
updateRetryInfoTimeout, updateRetryInfoTimeout,
ReserveUpdatedEventRecord, ReserveUpdatedEventRecord,
WalletReserveHistoryItemType, WalletReserveHistoryItemType,
DenominationRecord,
PlanchetRecord, PlanchetRecord,
WithdrawalSourceType, WithdrawalSourceType,
} from "../types/dbTypes"; } from "../types/dbTypes";
@ -593,33 +592,6 @@ export async function confirmReserve(
}); });
} }
async function makePlanchet(
ws: InternalWalletState,
reserve: ReserveRecord,
denom: DenominationRecord,
): Promise<PlanchetRecord> {
const r = await ws.cryptoApi.createPlanchet({
denomPub: denom.denomPub,
feeWithdraw: denom.feeWithdraw,
reservePriv: reserve.reservePriv,
reservePub: reserve.reservePub,
value: denom.value,
});
return {
blindingKey: r.blindingKey,
coinEv: r.coinEv,
coinPriv: r.coinPriv,
coinPub: r.coinPub,
coinValue: r.coinValue,
denomPub: r.denomPub,
denomPubHash: r.denomPubHash,
isFromTip: false,
reservePub: r.reservePub,
withdrawSig: r.withdrawSig,
coinEvHash: r.coinEvHash,
};
}
/** /**
* Withdraw coins from a reserve until it is empty. * Withdraw coins from a reserve until it is empty.
* *
@ -654,7 +626,7 @@ async function depleteReserve(
withdrawAmount, withdrawAmount,
); );
logger.trace(`got denom list`); logger.trace(`got denom list`);
if (denomsForWithdraw.length === 0) { if (!denomsForWithdraw) {
// Only complain about inability to withdraw if we // Only complain about inability to withdraw if we
// didn't withdraw before. // didn't withdraw before.
if (Amounts.isZero(summary.withdrawnAmount)) { if (Amounts.isZero(summary.withdrawnAmount)) {
@ -675,15 +647,42 @@ async function depleteReserve(
const withdrawalGroupId = encodeCrock(randomBytes(32)); const withdrawalGroupId = encodeCrock(randomBytes(32));
const totalCoinValue = Amounts.sum(denomsForWithdraw.map((x) => x.value))
.amount;
const planchets: PlanchetRecord[] = []; const planchets: PlanchetRecord[] = [];
for (const d of denomsForWithdraw) { let coinIdx = 0;
const p = await makePlanchet(ws, reserve, d); for (let i = 0; i < denomsForWithdraw.selectedDenoms.length; i++) {
planchets.push(p); const d = denomsForWithdraw.selectedDenoms[i];
const denom = d.denom;
for (let j = 0; j < d.count; j++) {
const r = await ws.cryptoApi.createPlanchet({
denomPub: denom.denomPub,
feeWithdraw: denom.feeWithdraw,
reservePriv: reserve.reservePriv,
reservePub: reserve.reservePub,
value: denom.value,
});
const planchet: PlanchetRecord = {
blindingKey: r.blindingKey,
coinEv: r.coinEv,
coinEvHash: r.coinEvHash,
coinIdx,
coinPriv: r.coinPriv,
coinPub: r.coinPub,
coinValue: r.coinValue,
denomPub: r.denomPub,
denomPubHash: r.denomPubHash,
isFromTip: false,
reservePub: r.reservePub,
withdrawalDone: false,
withdrawSig: r.withdrawSig,
withdrawalGroupId: withdrawalGroupId,
};
planchets.push(planchet);
coinIdx++;
}
} }
logger.trace("created plachets");
const withdrawalRecord: WithdrawalGroupRecord = { const withdrawalRecord: WithdrawalGroupRecord = {
withdrawalGroupId: withdrawalGroupId, withdrawalGroupId: withdrawalGroupId,
exchangeBaseUrl: reserve.exchangeBaseUrl, exchangeBaseUrl: reserve.exchangeBaseUrl,
@ -693,23 +692,24 @@ async function depleteReserve(
}, },
rawWithdrawalAmount: withdrawAmount, rawWithdrawalAmount: withdrawAmount,
timestampStart: getTimestampNow(), timestampStart: getTimestampNow(),
denoms: denomsForWithdraw.map((x) => x.denomPub),
withdrawn: denomsForWithdraw.map((x) => false),
planchets,
totalCoinValue,
retryInfo: initRetryInfo(), retryInfo: initRetryInfo(),
lastErrorPerCoin: {}, lastErrorPerCoin: {},
lastError: undefined, lastError: undefined,
denomsSel: {
totalCoinValue: denomsForWithdraw.totalCoinValue,
totalWithdrawCost: denomsForWithdraw.totalWithdrawCost,
selectedDenoms: denomsForWithdraw.selectedDenoms.map((x) => {
return {
countAllocated: x.count,
countPlanchetCreated: x.count,
denomPubHash: x.denom.denomPubHash,
};
}),
},
}; };
const totalCoinWithdrawFee = Amounts.sum(
denomsForWithdraw.map((x) => x.feeWithdraw),
).amount;
const totalWithdrawAmount = Amounts.add(totalCoinValue, totalCoinWithdrawFee)
.amount;
const success = await ws.db.runWithWriteTransaction( const success = await ws.db.runWithWriteTransaction(
[Stores.withdrawalGroups, Stores.reserves], [Stores.withdrawalGroups, Stores.reserves, Stores.planchets],
async (tx) => { async (tx) => {
const newReserve = await tx.get(Stores.reserves, reservePub); const newReserve = await tx.get(Stores.reserves, reservePub);
if (!newReserve) { if (!newReserve) {
@ -723,7 +723,10 @@ async function depleteReserve(
newReserve.currency, newReserve.currency,
); );
if ( if (
Amounts.cmp(newSummary.unclaimedReserveAmount, totalWithdrawAmount) < 0 Amounts.cmp(
newSummary.unclaimedReserveAmount,
denomsForWithdraw.totalWithdrawCost,
) < 0
) { ) {
// Something must have happened concurrently! // Something must have happened concurrently!
logger.error( logger.error(
@ -731,20 +734,23 @@ async function depleteReserve(
); );
return false; return false;
} }
for (let i = 0; i < planchets.length; i++) { for (let i = 0; i < denomsForWithdraw.selectedDenoms.length; i++) {
const amt = Amounts.add( const sd = denomsForWithdraw.selectedDenoms[i];
denomsForWithdraw[i].value, for (let j = 0; j < sd.count; j++) {
denomsForWithdraw[i].feeWithdraw, const amt = Amounts.add(sd.denom.value, sd.denom.feeWithdraw).amount;
).amount; newReserve.reserveTransactions.push({
newReserve.reserveTransactions.push({ type: WalletReserveHistoryItemType.Withdraw,
type: WalletReserveHistoryItemType.Withdraw, expectedAmount: amt,
expectedAmount: amt, });
}); }
} }
newReserve.reserveStatus = ReserveRecordStatus.DORMANT; newReserve.reserveStatus = ReserveRecordStatus.DORMANT;
newReserve.retryInfo = initRetryInfo(false); newReserve.retryInfo = initRetryInfo(false);
await tx.put(Stores.reserves, newReserve); await tx.put(Stores.reserves, newReserve);
await tx.put(Stores.withdrawalGroups, withdrawalRecord); await tx.put(Stores.withdrawalGroups, withdrawalRecord);
for (const p of planchets) {
await tx.put(Stores.planchets, p);
}
return true; return true;
}, },
); );

View File

@ -30,6 +30,7 @@ import {
initRetryInfo, initRetryInfo,
updateRetryInfoTimeout, updateRetryInfoTimeout,
WithdrawalSourceType, WithdrawalSourceType,
TipPlanchet,
} from "../types/dbTypes"; } from "../types/dbTypes";
import { import {
getExchangeWithdrawalInfo, getExchangeWithdrawalInfo,
@ -72,6 +73,7 @@ export async function getTipStatus(
]); ]);
if (!tipRecord) { if (!tipRecord) {
await updateExchangeFromUrl(ws, tipPickupStatus.exchange_url);
const withdrawDetails = await getExchangeWithdrawalInfo( const withdrawDetails = await getExchangeWithdrawalInfo(
ws, ws,
tipPickupStatus.exchange_url, tipPickupStatus.exchange_url,
@ -79,6 +81,11 @@ export async function getTipStatus(
); );
const tipId = encodeCrock(getRandomBytes(32)); const tipId = encodeCrock(getRandomBytes(32));
const selectedDenoms = await getVerifiedWithdrawDenomList(
ws,
tipPickupStatus.exchange_url,
amount,
);
tipRecord = { tipRecord = {
tipId, tipId,
@ -100,6 +107,17 @@ export async function getTipStatus(
).amount, ).amount,
retryInfo: initRetryInfo(), retryInfo: initRetryInfo(),
lastError: undefined, lastError: undefined,
denomsSel: {
totalCoinValue: selectedDenoms.totalCoinValue,
totalWithdrawCost: selectedDenoms.totalWithdrawCost,
selectedDenoms: selectedDenoms.selectedDenoms.map((x) => {
return {
countAllocated: x.count,
countPlanchetCreated: x.count,
denomPubHash: x.denom.denomPubHash,
};
}),
},
}; };
await ws.db.put(Stores.tips, tipRecord); await ws.db.put(Stores.tips, tipRecord);
} }
@ -185,18 +203,21 @@ async function processTipImpl(
return; return;
} }
const denomsForWithdraw = tipRecord.denomsSel;
if (!tipRecord.planchets) { if (!tipRecord.planchets) {
await updateExchangeFromUrl(ws, tipRecord.exchangeUrl); const planchets: TipPlanchet[] = [];
const denomsForWithdraw = await getVerifiedWithdrawDenomList(
ws,
tipRecord.exchangeUrl,
tipRecord.amount,
);
const planchets = await Promise.all(
denomsForWithdraw.map((d) => ws.cryptoApi.createTipPlanchet(d)),
);
for (const sd of denomsForWithdraw.selectedDenoms) {
const denom = await ws.db.getIndexed(Stores.denominations.denomPubHashIndex, sd.denomPubHash);
if (!denom) {
throw Error("denom does not exist anymore");
}
for (let i = 0; i < sd.countAllocated; i++) {
const r = await ws.cryptoApi.createTipPlanchet(denom);
planchets.push(r);
}
}
await ws.db.mutate(Stores.tips, tipId, (r) => { await ws.db.mutate(Stores.tips, tipId, (r) => {
if (!r.planchets) { if (!r.planchets) {
r.planchets = planchets; r.planchets = planchets;
@ -244,6 +265,7 @@ async function processTipImpl(
throw Error("number of tip responses does not match requested planchets"); throw Error("number of tip responses does not match requested planchets");
} }
const withdrawalGroupId = encodeCrock(getRandomBytes(32));
const planchets: PlanchetRecord[] = []; const planchets: PlanchetRecord[] = [];
for (let i = 0; i < tipRecord.planchets.length; i++) { for (let i = 0; i < tipRecord.planchets.length; i++) {
@ -261,16 +283,15 @@ async function processTipImpl(
withdrawSig: response.reserve_sigs[i].reserve_sig, withdrawSig: response.reserve_sigs[i].reserve_sig,
isFromTip: true, isFromTip: true,
coinEvHash, coinEvHash,
coinIdx: i,
withdrawalDone: false,
withdrawalGroupId: withdrawalGroupId,
}; };
planchets.push(planchet); planchets.push(planchet);
} }
const withdrawalGroupId = encodeCrock(getRandomBytes(32));
const withdrawalGroup: WithdrawalGroupRecord = { const withdrawalGroup: WithdrawalGroupRecord = {
denoms: planchets.map((x) => x.denomPub),
exchangeBaseUrl: tipRecord.exchangeUrl, exchangeBaseUrl: tipRecord.exchangeUrl,
planchets: planchets,
source: { source: {
type: WithdrawalSourceType.Tip, type: WithdrawalSourceType.Tip,
tipId: tipRecord.tipId, tipId: tipRecord.tipId,
@ -278,12 +299,11 @@ async function processTipImpl(
timestampStart: getTimestampNow(), timestampStart: getTimestampNow(),
withdrawalGroupId: withdrawalGroupId, withdrawalGroupId: withdrawalGroupId,
rawWithdrawalAmount: tipRecord.amount, rawWithdrawalAmount: tipRecord.amount,
withdrawn: planchets.map((x) => false),
totalCoinValue: Amounts.sum(planchets.map((p) => p.coinValue)).amount,
lastErrorPerCoin: {}, lastErrorPerCoin: {},
retryInfo: initRetryInfo(), retryInfo: initRetryInfo(),
timestampFinish: undefined, timestampFinish: undefined,
lastError: undefined, lastError: undefined,
denomsSel: tipRecord.denomsSel,
}; };
await ws.db.runWithWriteTransaction( await ws.db.runWithWriteTransaction(
@ -301,12 +321,13 @@ async function processTipImpl(
await tx.put(Stores.tips, tr); await tx.put(Stores.tips, tr);
await tx.put(Stores.withdrawalGroups, withdrawalGroup); await tx.put(Stores.withdrawalGroups, withdrawalGroup);
for (const p of planchets) {
await tx.put(Stores.planchets, p);
}
}, },
); );
await processWithdrawGroup(ws, withdrawalGroupId); await processWithdrawGroup(ws, withdrawalGroupId);
return;
} }
export async function acceptTip( export async function acceptTip(

View File

@ -14,7 +14,7 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/ */
import { AmountJson } from "../util/amounts"; import { AmountJson, Amounts } from "../util/amounts";
import { import {
DenominationRecord, DenominationRecord,
Stores, Stores,
@ -24,8 +24,8 @@ import {
initRetryInfo, initRetryInfo,
updateRetryInfoTimeout, updateRetryInfoTimeout,
CoinSourceType, CoinSourceType,
DenominationSelectionInfo,
} from "../types/dbTypes"; } from "../types/dbTypes";
import * as Amounts from "../util/amounts";
import { import {
BankWithdrawDetails, BankWithdrawDetails,
ExchangeWithdrawDetails, ExchangeWithdrawDetails,
@ -74,33 +74,52 @@ function isWithdrawableDenom(d: DenominationRecord): boolean {
export function getWithdrawDenomList( export function getWithdrawDenomList(
amountAvailable: AmountJson, amountAvailable: AmountJson,
denoms: DenominationRecord[], denoms: DenominationRecord[],
): DenominationRecord[] { ): DenominationSelectionInfo {
let remaining = Amounts.copy(amountAvailable); let remaining = Amounts.copy(amountAvailable);
const ds: DenominationRecord[] = [];
const selectedDenoms: {
count: number;
denom: DenominationRecord;
}[] = [];
let totalCoinValue = Amounts.getZero(amountAvailable.currency);
let totalWithdrawCost = Amounts.getZero(amountAvailable.currency);
denoms = denoms.filter(isWithdrawableDenom); denoms = denoms.filter(isWithdrawableDenom);
denoms.sort((d1, d2) => Amounts.cmp(d2.value, d1.value)); denoms.sort((d1, d2) => Amounts.cmp(d2.value, d1.value));
// This is an arbitrary number of coins for (const d of denoms) {
// we can withdraw in one go. It's not clear if this limit let count = 0;
// is useful ... const cost = Amounts.add(d.value, d.feeWithdraw).amount;
for (let i = 0; i < 1000; i++) { for (;;) {
let found = false;
for (const d of denoms) {
const cost = Amounts.add(d.value, d.feeWithdraw).amount;
if (Amounts.cmp(remaining, cost) < 0) { if (Amounts.cmp(remaining, cost) < 0) {
continue; break;
} }
found = true;
remaining = Amounts.sub(remaining, cost).amount; remaining = Amounts.sub(remaining, cost).amount;
ds.push(d); count++;
break;
} }
if (!found) { if (count > 0) {
totalCoinValue = Amounts.add(
totalCoinValue,
Amounts.mult(d.value, count).amount,
).amount;
totalWithdrawCost = Amounts.add(totalWithdrawCost, cost).amount;
selectedDenoms.push({
count,
denom: d,
});
}
if (Amounts.isZero(remaining)) {
break; break;
} }
} }
return ds;
return {
selectedDenoms,
totalCoinValue,
totalWithdrawCost,
};
} }
/** /**
@ -167,14 +186,18 @@ async function processPlanchet(
if (!withdrawalGroup) { if (!withdrawalGroup) {
return; return;
} }
if (withdrawalGroup.withdrawn[coinIdx]) { const planchet = await ws.db.getIndexed(Stores.planchets.byGroupAndIndex, [
return; withdrawalGroupId,
} coinIdx,
const planchet = withdrawalGroup.planchets[coinIdx]; ]);
if (!planchet) { if (!planchet) {
console.log("processPlanchet: planchet not found"); console.log("processPlanchet: planchet not found");
return; return;
} }
if (planchet.withdrawalDone) {
console.log("processPlanchet: planchet already withdrawn");
return;
}
const exchange = await ws.db.get( const exchange = await ws.db.get(
Stores.exchanges, Stores.exchanges,
withdrawalGroup.exchangeBaseUrl, withdrawalGroup.exchangeBaseUrl,
@ -243,25 +266,32 @@ async function processPlanchet(
let withdrawalGroupFinished = false; let withdrawalGroupFinished = false;
const success = await ws.db.runWithWriteTransaction( const success = await ws.db.runWithWriteTransaction(
[Stores.coins, Stores.withdrawalGroups, Stores.reserves], [Stores.coins, Stores.withdrawalGroups, Stores.reserves, Stores.planchets],
async (tx) => { async (tx) => {
const ws = await tx.get(Stores.withdrawalGroups, withdrawalGroupId); const ws = await tx.get(Stores.withdrawalGroups, withdrawalGroupId);
if (!ws) { if (!ws) {
return false; return false;
} }
if (ws.withdrawn[coinIdx]) { const p = await tx.get(Stores.planchets, planchet.coinPub);
if (!p) {
return false;
}
if (p.withdrawalDone) {
// Already withdrawn // Already withdrawn
return false; return false;
} }
ws.withdrawn[coinIdx] = true; p.withdrawalDone = true;
delete ws.lastErrorPerCoin[coinIdx]; await tx.put(Stores.planchets, p);
let numDone = 0;
for (let i = 0; i < ws.withdrawn.length; i++) { let numNotDone = 0;
if (ws.withdrawn[i]) {
numDone++; await tx.iterIndexed(Stores.planchets.byGroup, withdrawalGroupId).forEach((x) => {
if (!x.withdrawalDone) {
numNotDone++;
} }
} });
if (numDone === ws.denoms.length) {
if (numNotDone == 0) {
ws.timestampFinish = getTimestampNow(); ws.timestampFinish = getTimestampNow();
ws.lastError = undefined; ws.lastError = undefined;
ws.retryInfo = initRetryInfo(false); ws.retryInfo = initRetryInfo(false);
@ -298,7 +328,7 @@ export async function getVerifiedWithdrawDenomList(
ws: InternalWalletState, ws: InternalWalletState,
exchangeBaseUrl: string, exchangeBaseUrl: string,
amount: AmountJson, amount: AmountJson,
): Promise<DenominationRecord[]> { ): Promise<DenominationSelectionInfo> {
const exchange = await ws.db.get(Stores.exchanges, exchangeBaseUrl); const exchange = await ws.db.get(Stores.exchanges, exchangeBaseUrl);
if (!exchange) { if (!exchange) {
console.log("exchange not found"); console.log("exchange not found");
@ -318,14 +348,18 @@ export async function getVerifiedWithdrawDenomList(
let allValid = false; let allValid = false;
let selectedDenoms: DenominationRecord[]; let selectedDenoms: DenominationSelectionInfo;
do { do {
allValid = true; allValid = true;
const nextPossibleDenoms = []; const nextPossibleDenoms = [];
selectedDenoms = getWithdrawDenomList(amount, possibleDenoms); selectedDenoms = getWithdrawDenomList(amount, possibleDenoms);
console.log("got withdraw denom list"); console.log("got withdraw denom list");
for (const denom of selectedDenoms || []) { if (!selectedDenoms) {
console;
}
for (const denomSel of selectedDenoms.selectedDenoms) {
const denom = denomSel.denom;
if (denom.status === DenominationStatus.Unverified) { if (denom.status === DenominationStatus.Unverified) {
console.log( console.log(
"checking validity", "checking validity",
@ -349,7 +383,7 @@ export async function getVerifiedWithdrawDenomList(
nextPossibleDenoms.push(denom); nextPossibleDenoms.push(denom);
} }
} }
} while (selectedDenoms.length > 0 && !allValid); } while (selectedDenoms.selectedDenoms.length > 0 && !allValid);
console.log("returning denoms"); console.log("returning denoms");
@ -402,6 +436,23 @@ async function resetWithdrawalGroupRetry(
}); });
} }
async function processInBatches(workGen: Iterator<Promise<void>>, batchSize: number): Promise<void> {
for (;;) {
const batch: Promise<void>[] = [];
for (let i = 0; i < batchSize; i++) {
const wn = workGen.next();
if (wn.done) {
break;
}
batch.push(wn.value);
}
if (batch.length == 0) {
break;
}
await Promise.all(batch);
}
}
async function processWithdrawGroupImpl( async function processWithdrawGroupImpl(
ws: InternalWalletState, ws: InternalWalletState,
withdrawalGroupId: string, withdrawalGroupId: string,
@ -420,11 +471,21 @@ async function processWithdrawGroupImpl(
return; return;
} }
const ps = withdrawalGroup.denoms.map((d, i) => const numDenoms = withdrawalGroup.denomsSel.selectedDenoms.length;
processPlanchet(ws, withdrawalGroupId, i), const genWork = function*(): Iterator<Promise<void>> {
); let coinIdx = 0;
await Promise.all(ps); for (let i = 0; i < numDenoms; i++) {
return; const count = withdrawalGroup.denomsSel.selectedDenoms[i].countAllocated;
for (let j = 0; j < count; j++) {
yield processPlanchet(ws, withdrawalGroupId, coinIdx);
coinIdx++;
}
}
}
// Withdraw coins in batches.
// The batch size is relatively large
await processInBatches(genWork(), 50);
} }
export async function getExchangeWithdrawalInfo( export async function getExchangeWithdrawalInfo(
@ -447,14 +508,6 @@ export async function getExchangeWithdrawalInfo(
baseUrl, baseUrl,
amount, amount,
); );
let acc = Amounts.getZero(amount.currency);
for (const d of selectedDenoms) {
acc = Amounts.add(acc, d.feeWithdraw).amount;
}
const actualCoinCost = selectedDenoms
.map((d: DenominationRecord) => Amounts.add(d.value, d.feeWithdraw).amount)
.reduce((a, b) => Amounts.add(a, b).amount);
const exchangeWireAccounts: string[] = []; const exchangeWireAccounts: string[] = [];
for (const account of exchangeWireInfo.accounts) { for (const account of exchangeWireInfo.accounts) {
exchangeWireAccounts.push(account.payto_uri); exchangeWireAccounts.push(account.payto_uri);
@ -462,9 +515,11 @@ export async function getExchangeWithdrawalInfo(
const { isTrusted, isAudited } = await getExchangeTrust(ws, exchangeInfo); const { isTrusted, isAudited } = await getExchangeTrust(ws, exchangeInfo);
let earliestDepositExpiration = selectedDenoms[0].stampExpireDeposit; let earliestDepositExpiration =
for (let i = 1; i < selectedDenoms.length; i++) { selectedDenoms.selectedDenoms[0].denom.stampExpireDeposit;
const expireDeposit = selectedDenoms[i].stampExpireDeposit; for (let i = 1; i < selectedDenoms.selectedDenoms.length; i++) {
const expireDeposit =
selectedDenoms.selectedDenoms[i].denom.stampExpireDeposit;
if (expireDeposit.t_ms < earliestDepositExpiration.t_ms) { if (expireDeposit.t_ms < earliestDepositExpiration.t_ms) {
earliestDepositExpiration = expireDeposit; earliestDepositExpiration = expireDeposit;
} }
@ -512,6 +567,11 @@ export async function getExchangeWithdrawalInfo(
} }
} }
const withdrawFee = Amounts.sub(
selectedDenoms.totalWithdrawCost,
selectedDenoms.totalCoinValue,
).amount;
const ret: ExchangeWithdrawDetails = { const ret: ExchangeWithdrawDetails = {
earliestDepositExpiration, earliestDepositExpiration,
exchangeInfo, exchangeInfo,
@ -520,13 +580,13 @@ export async function getExchangeWithdrawalInfo(
isAudited, isAudited,
isTrusted, isTrusted,
numOfferedDenoms: possibleDenoms.length, numOfferedDenoms: possibleDenoms.length,
overhead: Amounts.sub(amount, actualCoinCost).amount, overhead: Amounts.sub(amount, selectedDenoms.totalWithdrawCost).amount,
selectedDenoms, selectedDenoms,
trustedAuditorPubs, trustedAuditorPubs,
versionMatch, versionMatch,
walletVersion: WALLET_EXCHANGE_PROTOCOL_VERSION, walletVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
wireFees: exchangeWireInfo, wireFees: exchangeWireInfo,
withdrawFee: acc, withdrawFee,
termsOfServiceAccepted: tosAccepted, termsOfServiceAccepted: tosAccepted,
}; };
return ret; return ret;

View File

@ -1,17 +1,17 @@
/* /*
This file is part of TALER This file is part of GNU Taler
(C) 2018 GNUnet e.V. and INRIA (C) 2018-2020 Taler Systems S.A.
TALER is free software; you can redistribute it and/or modify it under the GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version. Foundation; either version 3, or (at your option) any later version.
TALER is distributed in the hope that it will be useful, but WITHOUT ANY GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details. A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with You should have received a copy of the GNU General Public License along with
TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/ */
/** /**
@ -608,7 +608,25 @@ export interface PlanchetRecord {
* Public key of the coin. * Public key of the coin.
*/ */
coinPub: string; coinPub: string;
/**
* Private key of the coin.
*/
coinPriv: string; coinPriv: string;
/**
* Withdrawal group that this planchet belongs to
* (or the empty string).
*/
withdrawalGroupId: string;
/**
* Index within the withdrawal group (or -1).
*/
coinIdx: number;
withdrawalDone: boolean;
/** /**
* Public key of the reserve, this might be a reserve not * Public key of the reserve, this might be a reserve not
* known to the wallet if the planchet is from a tip. * known to the wallet if the planchet is from a tip.
@ -889,6 +907,8 @@ export interface TipRecord {
*/ */
planchets?: TipPlanchet[]; planchets?: TipPlanchet[];
denomsSel: DenomSelectionState;
/** /**
* Response if the merchant responded, * Response if the merchant responded,
* undefined otherwise. * undefined otherwise.
@ -1356,6 +1376,28 @@ export interface WithdrawalSourceReserve {
export type WithdrawalSource = WithdrawalSourceTip | WithdrawalSourceReserve; export type WithdrawalSource = WithdrawalSourceTip | WithdrawalSourceReserve;
export interface DenominationSelectionInfo {
totalCoinValue: AmountJson;
totalWithdrawCost: AmountJson;
selectedDenoms: {
/**
* How many times do we withdraw this denomination?
*/
count: number;
denom: DenominationRecord;
}[];
}
export interface DenomSelectionState {
totalCoinValue: AmountJson;
totalWithdrawCost: AmountJson;
selectedDenoms: {
denomPubHash: string;
countAllocated: number;
countPlanchetCreated: number;
}[];
}
export interface WithdrawalGroupRecord { export interface WithdrawalGroupRecord {
withdrawalGroupId: string; withdrawalGroupId: string;
@ -1379,22 +1421,13 @@ export interface WithdrawalGroupRecord {
*/ */
timestampFinish?: Timestamp; timestampFinish?: Timestamp;
totalCoinValue: AmountJson;
/** /**
* Amount including fees (i.e. the amount subtracted from the * Amount including fees (i.e. the amount subtracted from the
* reserve to withdraw all coins in this withdrawal session). * reserve to withdraw all coins in this withdrawal session).
*/ */
rawWithdrawalAmount: AmountJson; rawWithdrawalAmount: AmountJson;
denoms: string[]; denomsSel: DenomSelectionState;
planchets: (undefined | PlanchetRecord)[];
/**
* Coins in this session that are withdrawn are set to true.
*/
withdrawn: boolean[];
/** /**
* Retry info, always present even on completed operations so that indexing works. * Retry info, always present even on completed operations so that indexing works.
@ -1625,6 +1658,22 @@ export namespace Stores {
} }
} }
class PlanchetsStore extends Store<PlanchetRecord> {
constructor() {
super("planchets", { keyPath: "coinPub" });
}
byGroupAndIndex = new Index<string, PlanchetRecord>(
this,
"withdrawalGroupAndCoinIdxIndex",
["withdrawalGroupId", "coinIdx"],
);
byGroup = new Index<string, PlanchetRecord>(
this,
"withdrawalGroupIndex",
"withdrawalGroupId",
);
}
class RefundEventsStore extends Store<RefundEventRecord> { class RefundEventsStore extends Store<RefundEventRecord> {
constructor() { constructor() {
super("refundEvents", { keyPath: "refundGroupId" }); super("refundEvents", { keyPath: "refundGroupId" });
@ -1681,6 +1730,7 @@ export namespace Stores {
export const tips = new TipsStore(); export const tips = new TipsStore();
export const senderWires = new SenderWiresStore(); export const senderWires = new SenderWiresStore();
export const withdrawalGroups = new WithdrawalGroupsStore(); export const withdrawalGroups = new WithdrawalGroupsStore();
export const planchets = new PlanchetsStore();
export const bankWithdrawUris = new BankWithdrawUrisStore(); export const bankWithdrawUris = new BankWithdrawUrisStore();
export const refundEvents = new RefundEventsStore(); export const refundEvents = new RefundEventsStore();
export const payEvents = new PayEventsStore(); export const payEvents = new PayEventsStore();

View File

@ -30,9 +30,9 @@
import { AmountJson, codecForAmountJson } from "../util/amounts"; import { AmountJson, codecForAmountJson } from "../util/amounts";
import * as LibtoolVersion from "../util/libtoolVersion"; import * as LibtoolVersion from "../util/libtoolVersion";
import { import {
DenominationRecord,
ExchangeRecord, ExchangeRecord,
ExchangeWireInfo, ExchangeWireInfo,
DenominationSelectionInfo,
} from "./dbTypes"; } from "./dbTypes";
import { Timestamp } from "../util/time"; import { Timestamp } from "../util/time";
import { import {
@ -77,7 +77,7 @@ export interface ExchangeWithdrawDetails {
/** /**
* Selected denominations for withdraw. * Selected denominations for withdraw.
*/ */
selectedDenoms: DenominationRecord[]; selectedDenoms: DenominationSelectionInfo;
/** /**
* Fees for withdraw. * Fees for withdraw.

View File

@ -332,6 +332,33 @@ function check(a: any): boolean {
} }
} }
function mult(a: AmountJson, n: number): Result {
if (!Number.isInteger(n)) {
throw Error("amount can only be multipied by an integer");
}
if (n < 0) {
throw Error("amount can only be multiplied by a positive integer");
}
if (n == 0) {
return { amount: getZero(a.currency), saturated: false };
}
let acc = {... a};
while (n > 1) {
let r: Result;
if (n % 2 == 0) {
n = n / 2;
r = add(acc, acc);
} else {
r = add(acc, a);
}
if (r.saturated) {
return r;
}
acc = r.amount;
}
return { amount: acc, saturated: false };
}
// Export all amount-related functions here for better IDE experience. // Export all amount-related functions here for better IDE experience.
export const Amounts = { export const Amounts = {
stringify: stringify, stringify: stringify,
@ -341,9 +368,11 @@ export const Amounts = {
add: add, add: add,
sum: sum, sum: sum,
sub: sub, sub: sub,
mult: mult,
check: check, check: check,
getZero: getZero, getZero: getZero,
isZero: isZero, isZero: isZero,
maxAmountValue: maxAmountValue, maxAmountValue: maxAmountValue,
fromFloat: fromFloat, fromFloat: fromFloat,
copy: copy,
}; };

View File

@ -25,7 +25,6 @@
*/ */
import { AmountJson } from "../util/amounts"; import { AmountJson } from "../util/amounts";
import * as Amounts from "../util/amounts"; import * as Amounts from "../util/amounts";
import { DenominationRecord } from "../types/dbTypes";
import { ExchangeWithdrawDetails } from "../types/walletTypes"; import { ExchangeWithdrawDetails } from "../types/walletTypes";
import * as i18n from "./i18n"; import * as i18n from "./i18n";
import React from "react"; import React from "react";
@ -208,31 +207,6 @@ function FeeDetailsView(props: {
} }
const denoms = rci.selectedDenoms; const denoms = rci.selectedDenoms;
const countByPub: { [s: string]: number } = {};
const uniq: DenominationRecord[] = [];
denoms.forEach((x: DenominationRecord) => {
let c = countByPub[x.denomPub] || 0;
if (c === 0) {
uniq.push(x);
}
c += 1;
countByPub[x.denomPub] = c;
});
function row(denom: DenominationRecord): JSX.Element {
return (
<tr>
<td>{countByPub[denom.denomPub] + "x"}</td>
<td>{renderAmount(denom.value)}</td>
<td>{renderAmount(denom.feeWithdraw)}</td>
<td>{renderAmount(denom.feeRefresh)}</td>
<td>{renderAmount(denom.feeDeposit)}</td>
</tr>
);
}
const withdrawFee = renderAmount(rci.withdrawFee); const withdrawFee = renderAmount(rci.withdrawFee);
const overhead = renderAmount(rci.overhead); const overhead = renderAmount(rci.overhead);
@ -266,7 +240,19 @@ function FeeDetailsView(props: {
<th>{i18n.str`Deposit Fee`}</th> <th>{i18n.str`Deposit Fee`}</th>
</tr> </tr>
</thead> </thead>
<tbody>{uniq.map(row)}</tbody> <tbody>
{denoms.selectedDenoms.map((ds) => {
return (
<tr key={ds.denom.denomPub}>
<td>{ds.count + "x"}</td>
<td>{renderAmount(ds.denom.value)}</td>
<td>{renderAmount(ds.denom.feeWithdraw)}</td>
<td>{renderAmount(ds.denom.feeRefresh)}</td>
<td>{renderAmount(ds.denom.feeDeposit)}</td>
</tr>
);
})}
</tbody>
</table> </table>
</div> </div>
<h3>Wire Fees</h3> <h3>Wire Fees</h3>