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(
amtExpected: string | AmountJson,
amtActual: string | AmountJson,
amtExpected: string | AmountJson,
): void {
let ja1: AmountJson;
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) {
if (Amounts.cmp(amtActual, amtExpected) != 0) {
throw Error(
`test assertion failed: expected ${Amounts.stringify(
ja1,
)} but got ${Amounts.stringify(ja2)}`,
amtExpected,
)} but got ${Amounts.stringify(amtActual)}`,
);
}
}
assertAmountLeq(
amtExpected: string | AmountJson,
amtActual: string | AmountJson,
a: string | AmountJson,
b: string | AmountJson,
): void {
let ja1: AmountJson;
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) {
if (Amounts.cmp(a, b) > 0) {
throw Error(
`test assertion failed: expected ${Amounts.stringify(
ja1,
)} to be less or equal (leq) than ${Amounts.stringify(ja2)}`,
a,
)} to be less or equal (leq) than ${Amounts.stringify(b)}`,
);
}
}

View File

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

View File

@ -90,7 +90,10 @@ export async function getBalancesInsideTransaction(
const b = initBalance(session.amountRefreshOutput.currency);
// We are always assuming the refresh will succeed, thus we
// 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(
[Stores.coins, Stores.purchases, Stores.proposals, Stores.refreshGroups],
[
Stores.coins,
Stores.purchases,
Stores.proposals,
Stores.refreshGroups,
Stores.denominations,
],
async (tx) => {
const p = await tx.get(Stores.proposals, proposal.proposalId);
if (p) {

View File

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

View File

@ -31,7 +31,7 @@ import { amountToPretty } from "../util/helpers";
import { TransactionHandle } from "../util/query";
import { InternalWalletState, EXCHANGE_COINS_LOCK } from "./state";
import { Logger } from "../util/logging";
import { getWithdrawDenomList } from "./withdraw";
import { getWithdrawDenomList, isWithdrawableDenom } from "./withdraw";
import { updateExchangeFromUrl } from "./exchanges";
import {
TalerErrorDetails,
@ -49,6 +49,7 @@ import {
codecForExchangeRevealResponse,
} from "../types/talerTypes";
import { URL } from "../util/url";
import { checkDbInvariant } from "../util/invariants";
const logger = new Logger("refresh.ts");
@ -132,8 +133,10 @@ async function refreshCreateSession(
.iterIndex(Stores.denominations.exchangeBaseUrlIndex, exchange.baseUrl)
.toArray();
const availableAmount = Amounts.sub(coin.currentAmount, oldDenom.feeRefresh)
.amount;
const availableAmount = Amounts.sub(
refreshGroup.inputPerCoin[coinIndex],
oldDenom.feeRefresh,
).amount;
const newCoinDenoms = getWithdrawDenomList(availableAmount, availableDenoms);
@ -177,22 +180,10 @@ async function refreshCreateSession(
oldDenom.feeRefresh,
);
// Store refresh session and subtract refreshed amount from
// coin in the same transaction.
// Store refresh session for this coin in the database.
await ws.db.runWithWriteTransaction(
[Stores.refreshGroups, Stores.coins],
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);
if (!rg) {
return;
@ -202,7 +193,6 @@ async function refreshCreateSession(
}
rg.refreshSessionPerCoin[coinIndex] = refreshSession;
await tx.put(Stores.refreshGroups, rg);
await tx.put(Stores.coins, c);
},
);
logger.info(
@ -552,6 +542,16 @@ async function processRefreshSession(
/**
* 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(
ws: InternalWalletState,
@ -561,6 +561,48 @@ export async function createRefreshGroup(
): Promise<RefreshGroupId> {
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 = {
timestampFinished: undefined,
finishedPerCoin: oldCoinPubs.map((x) => false),
@ -571,6 +613,8 @@ export async function createRefreshGroup(
refreshGroupId,
refreshSessionPerCoin: oldCoinPubs.map((x) => undefined),
retryInfo: initRetryInfo(),
inputPerCoin,
estimatedOutputPerCoin,
};
if (oldCoinPubs.length == 0) {

View File

@ -67,7 +67,11 @@ import { encodeCrock } from "../crypto/talerCrypto";
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 started = timestampCmp(now, d.stampStart) >= 0;
const lastPossibleWithdraw = timestampSubtractDuraction(

View File

@ -1014,6 +1014,10 @@ export interface RefreshGroupRecord {
refreshSessionPerCoin: (RefreshSessionRecord | undefined)[];
inputPerCoin: AmountJson[];
estimatedOutputPerCoin: AmountJson[];
/**
* Flag for each coin whether refreshing finished.
* 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
* 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) {
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
* 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 af = a.fraction % fractionalBase;
let s = av.toString();

View File

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