estimate refresh output, show correct(er) balance

This commit is contained in:
Florian Dold 2020-09-01 23:01:44 +05:30
parent 5f3d9835fa
commit 38e6d51946
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
10 changed files with 99 additions and 61 deletions

View File

@ -361,53 +361,27 @@ export class GlobalTestState {
} }
assertAmountEquals( assertAmountEquals(
amtExpected: string | AmountJson,
amtActual: string | AmountJson, amtActual: string | AmountJson,
amtExpected: string | AmountJson,
): void { ): void {
let ja1: AmountJson; if (Amounts.cmp(amtActual, amtExpected) != 0) {
let ja2: AmountJson;
if (typeof amtExpected === "string") {
ja1 = Amounts.parseOrThrow(amtExpected);
} else {
ja1 = amtExpected;
}
if (typeof amtActual === "string") {
ja2 = Amounts.parseOrThrow(amtActual);
} else {
ja2 = amtActual;
}
if (Amounts.cmp(ja1, ja2) != 0) {
throw Error( throw Error(
`test assertion failed: expected ${Amounts.stringify( `test assertion failed: expected ${Amounts.stringify(
ja1, amtExpected,
)} but got ${Amounts.stringify(ja2)}`, )} but got ${Amounts.stringify(amtActual)}`,
); );
} }
} }
assertAmountLeq( assertAmountLeq(
amtExpected: string | AmountJson, a: string | AmountJson,
amtActual: string | AmountJson, b: string | AmountJson,
): void { ): void {
let ja1: AmountJson; if (Amounts.cmp(a, b) > 0) {
let ja2: AmountJson;
if (typeof amtExpected === "string") {
ja1 = Amounts.parseOrThrow(amtExpected);
} else {
ja1 = amtExpected;
}
if (typeof amtActual === "string") {
ja2 = Amounts.parseOrThrow(amtActual);
} else {
ja2 = amtActual;
}
if (Amounts.cmp(ja1, ja2) > 0) {
throw Error( throw Error(
`test assertion failed: expected ${Amounts.stringify( `test assertion failed: expected ${Amounts.stringify(
ja1, a,
)} to be less or equal (leq) than ${Amounts.stringify(ja2)}`, )} to be less or equal (leq) than ${Amounts.stringify(b)}`,
); );
} }
} }

View File

@ -175,7 +175,7 @@ runTest(async (t: GlobalTestState) => {
.map((x) => x.amountRaw), .map((x) => x.amountRaw),
).amount; ).amount;
t.assertAmountEquals(raw, "TESTKUDOS:10"); t.assertAmountEquals("TESTKUDOS:10", raw);
const effective = Amounts.sum( const effective = Amounts.sum(
txs.transactions txs.transactions
@ -183,7 +183,7 @@ runTest(async (t: GlobalTestState) => {
.map((x) => x.amountEffective), .map((x) => x.amountEffective),
).amount; ).amount;
t.assertAmountEquals(effective, "TESTKUDOS:8.17"); t.assertAmountEquals("TESTKUDOS:8.33", effective);
} }
await t.shutdown(); await t.shutdown();

View File

@ -90,7 +90,10 @@ export async function getBalancesInsideTransaction(
const b = initBalance(session.amountRefreshOutput.currency); const b = initBalance(session.amountRefreshOutput.currency);
// We are always assuming the refresh will succeed, thus we // We are always assuming the refresh will succeed, thus we
// report the output as available balance. // report the output as available balance.
b.available = Amounts.add(session.amountRefreshOutput).amount; b.available = Amounts.add(b.available, session.amountRefreshOutput).amount;
} else {
const b = initBalance(r.inputPerCoin[i].currency);
b.available = Amounts.add(b.available, r.estimatedOutputPerCoin[i]).amount;
} }
} }
}); });

View File

@ -473,7 +473,13 @@ async function recordConfirmPay(
}; };
await ws.db.runWithWriteTransaction( await ws.db.runWithWriteTransaction(
[Stores.coins, Stores.purchases, Stores.proposals, Stores.refreshGroups], [
Stores.coins,
Stores.purchases,
Stores.proposals,
Stores.refreshGroups,
Stores.denominations,
],
async (tx) => { async (tx) => {
const p = await tx.get(Stores.proposals, proposal.proposalId); const p = await tx.get(Stores.proposals, proposal.proposalId);
if (p) { if (p) {

View File

@ -180,7 +180,7 @@ async function recoupWithdrawCoin(
// FIXME: verify that our expectations about the amount match // FIXME: verify that our expectations about the amount match
await ws.db.runWithWriteTransaction( await ws.db.runWithWriteTransaction(
[Stores.coins, Stores.reserves, Stores.recoupGroups], [Stores.coins, Stores.denominations, Stores.reserves, Stores.recoupGroups],
async (tx) => { async (tx) => {
const recoupGroup = await tx.get(Stores.recoupGroups, recoupGroupId); const recoupGroup = await tx.get(Stores.recoupGroups, recoupGroupId);
if (!recoupGroup) { if (!recoupGroup) {

View File

@ -31,7 +31,7 @@ import { amountToPretty } from "../util/helpers";
import { TransactionHandle } from "../util/query"; import { TransactionHandle } from "../util/query";
import { InternalWalletState, EXCHANGE_COINS_LOCK } from "./state"; import { InternalWalletState, EXCHANGE_COINS_LOCK } from "./state";
import { Logger } from "../util/logging"; import { Logger } from "../util/logging";
import { getWithdrawDenomList } from "./withdraw"; import { getWithdrawDenomList, isWithdrawableDenom } from "./withdraw";
import { updateExchangeFromUrl } from "./exchanges"; import { updateExchangeFromUrl } from "./exchanges";
import { import {
TalerErrorDetails, TalerErrorDetails,
@ -49,6 +49,7 @@ import {
codecForExchangeRevealResponse, codecForExchangeRevealResponse,
} from "../types/talerTypes"; } from "../types/talerTypes";
import { URL } from "../util/url"; import { URL } from "../util/url";
import { checkDbInvariant } from "../util/invariants";
const logger = new Logger("refresh.ts"); const logger = new Logger("refresh.ts");
@ -132,8 +133,10 @@ async function refreshCreateSession(
.iterIndex(Stores.denominations.exchangeBaseUrlIndex, exchange.baseUrl) .iterIndex(Stores.denominations.exchangeBaseUrlIndex, exchange.baseUrl)
.toArray(); .toArray();
const availableAmount = Amounts.sub(coin.currentAmount, oldDenom.feeRefresh) const availableAmount = Amounts.sub(
.amount; refreshGroup.inputPerCoin[coinIndex],
oldDenom.feeRefresh,
).amount;
const newCoinDenoms = getWithdrawDenomList(availableAmount, availableDenoms); const newCoinDenoms = getWithdrawDenomList(availableAmount, availableDenoms);
@ -177,22 +180,10 @@ async function refreshCreateSession(
oldDenom.feeRefresh, oldDenom.feeRefresh,
); );
// Store refresh session and subtract refreshed amount from // Store refresh session for this coin in the database.
// coin in the same transaction.
await ws.db.runWithWriteTransaction( await ws.db.runWithWriteTransaction(
[Stores.refreshGroups, Stores.coins], [Stores.refreshGroups, Stores.coins],
async (tx) => { async (tx) => {
const c = await tx.get(Stores.coins, coin.coinPub);
if (!c) {
throw Error("coin not found, but marked for refresh");
}
const r = Amounts.sub(c.currentAmount, refreshSession.amountRefreshInput);
if (r.saturated) {
logger.warn("can't refresh coin, no amount left");
return;
}
c.currentAmount = r.amount;
c.status = CoinStatus.Dormant;
const rg = await tx.get(Stores.refreshGroups, refreshGroupId); const rg = await tx.get(Stores.refreshGroups, refreshGroupId);
if (!rg) { if (!rg) {
return; return;
@ -202,7 +193,6 @@ async function refreshCreateSession(
} }
rg.refreshSessionPerCoin[coinIndex] = refreshSession; rg.refreshSessionPerCoin[coinIndex] = refreshSession;
await tx.put(Stores.refreshGroups, rg); await tx.put(Stores.refreshGroups, rg);
await tx.put(Stores.coins, c);
}, },
); );
logger.info( logger.info(
@ -552,6 +542,16 @@ async function processRefreshSession(
/** /**
* Create a refresh group for a list of coins. * Create a refresh group for a list of coins.
*
* Refreshes the remaining amount on the coin, effectively capturing the remaining
* value in the refresh group.
*
* The caller must ensure that
* the remaining amount was updated correctly before the coin was deposited or
* credited.
*
* The caller must also ensure that the coins that should be refreshed exist
* in the current database transaction.
*/ */
export async function createRefreshGroup( export async function createRefreshGroup(
ws: InternalWalletState, ws: InternalWalletState,
@ -561,6 +561,48 @@ export async function createRefreshGroup(
): Promise<RefreshGroupId> { ): Promise<RefreshGroupId> {
const refreshGroupId = encodeCrock(getRandomBytes(32)); const refreshGroupId = encodeCrock(getRandomBytes(32));
const inputPerCoin: AmountJson[] = [];
const estimatedOutputPerCoin: AmountJson[] = [];
const denomsPerExchange: Record<string, DenominationRecord[]> = {};
const getDenoms = async (
exchangeBaseUrl: string,
): Promise<DenominationRecord[]> => {
if (denomsPerExchange[exchangeBaseUrl]) {
return denomsPerExchange[exchangeBaseUrl];
}
const allDenoms = await tx
.iterIndexed(Stores.denominations.exchangeBaseUrlIndex, exchangeBaseUrl)
.filter((x) => {
return isWithdrawableDenom(x);
});
denomsPerExchange[exchangeBaseUrl] = allDenoms;
return allDenoms;
};
for (const ocp of oldCoinPubs) {
const coin = await tx.get(Stores.coins, ocp.coinPub);
checkDbInvariant(!!coin, "coin must be in database");
const denom = await tx.get(Stores.denominations, [
coin.exchangeBaseUrl,
coin.denomPub,
]);
checkDbInvariant(
!!denom,
"denomination for existing coin must be in database",
);
const refreshAmount = coin.currentAmount;
inputPerCoin.push(refreshAmount);
coin.currentAmount = Amounts.getZero(refreshAmount.currency);
coin.status = CoinStatus.Dormant;
await tx.put(Stores.coins, coin);
const denoms = await getDenoms(coin.exchangeBaseUrl);
const cost = getTotalRefreshCost(denoms, denom, refreshAmount);
const output = Amounts.sub(refreshAmount, cost).amount;
estimatedOutputPerCoin.push(output);
}
const refreshGroup: RefreshGroupRecord = { const refreshGroup: RefreshGroupRecord = {
timestampFinished: undefined, timestampFinished: undefined,
finishedPerCoin: oldCoinPubs.map((x) => false), finishedPerCoin: oldCoinPubs.map((x) => false),
@ -571,6 +613,8 @@ export async function createRefreshGroup(
refreshGroupId, refreshGroupId,
refreshSessionPerCoin: oldCoinPubs.map((x) => undefined), refreshSessionPerCoin: oldCoinPubs.map((x) => undefined),
retryInfo: initRetryInfo(), retryInfo: initRetryInfo(),
inputPerCoin,
estimatedOutputPerCoin,
}; };
if (oldCoinPubs.length == 0) { if (oldCoinPubs.length == 0) {

View File

@ -67,7 +67,11 @@ import { encodeCrock } from "../crypto/talerCrypto";
const logger = new Logger("withdraw.ts"); const logger = new Logger("withdraw.ts");
function isWithdrawableDenom(d: DenominationRecord): boolean { /**
* Check if a denom is withdrawable based on the expiration time
* and revocation state.
*/
export function isWithdrawableDenom(d: DenominationRecord): boolean {
const now = getTimestampNow(); const now = getTimestampNow();
const started = timestampCmp(now, d.stampStart) >= 0; const started = timestampCmp(now, d.stampStart) >= 0;
const lastPossibleWithdraw = timestampSubtractDuraction( const lastPossibleWithdraw = timestampSubtractDuraction(

View File

@ -1014,6 +1014,10 @@ export interface RefreshGroupRecord {
refreshSessionPerCoin: (RefreshSessionRecord | undefined)[]; refreshSessionPerCoin: (RefreshSessionRecord | undefined)[];
inputPerCoin: AmountJson[];
estimatedOutputPerCoin: AmountJson[];
/** /**
* Flag for each coin whether refreshing finished. * Flag for each coin whether refreshing finished.
* If a coin can't be refreshed (remaining value too small), * If a coin can't be refreshed (remaining value too small),

View File

@ -195,7 +195,9 @@ 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): -1 | 0 | 1 { export function cmp(a: AmountLike, b: AmountLike): -1 | 0 | 1 {
a = jsonifyAmount(a);
b = jsonifyAmount(b);
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}`);
} }
@ -310,7 +312,8 @@ export function fromFloat(floatVal: number, currency: string): AmountJson {
* Convert to standard human-readable string representation that's * Convert to standard human-readable string representation that's
* also used in JSON formats. * also used in JSON formats.
*/ */
export function stringify(a: AmountJson): string { export function stringify(a: AmountLike): string {
a = jsonifyAmount(a);
const av = a.value + Math.floor(a.fraction / fractionalBase); const av = a.value + Math.floor(a.fraction / fractionalBase);
const af = a.fraction % fractionalBase; const af = a.fraction % fractionalBase;
let s = av.toString(); let s = av.toString();

View File

@ -571,7 +571,7 @@ export class Wallet {
async refresh(oldCoinPub: string): Promise<void> { async refresh(oldCoinPub: string): Promise<void> {
try { try {
const refreshGroupId = await this.db.runWithWriteTransaction( const refreshGroupId = await this.db.runWithWriteTransaction(
[Stores.refreshGroups], [Stores.refreshGroups, Stores.denominations, Stores.coins],
async (tx) => { async (tx) => {
return await createRefreshGroup( return await createRefreshGroup(
this.ws, this.ws,