test recoup, fix bug in reserve state machine, fix bug in recoup-refresh
This commit is contained in:
parent
54c0d1c258
commit
9ec6018efe
@ -69,6 +69,9 @@ import {
|
||||
ApplyRefundRequest,
|
||||
codecForApplyRefundResponse,
|
||||
codecForAny,
|
||||
CoinDumpJson,
|
||||
ForceExchangeUpdateRequest,
|
||||
ForceRefreshRequest,
|
||||
} from "taler-wallet-core";
|
||||
import { URL } from "url";
|
||||
import axios, { AxiosError } from "axios";
|
||||
@ -1077,6 +1080,23 @@ export class ExchangeService implements ExchangeServiceInterface {
|
||||
);
|
||||
}
|
||||
|
||||
async revokeDenomination(denomPubHash: string) {
|
||||
if (this.isRunning()) {
|
||||
throw Error("exchange must be stopped when revoking denominations");
|
||||
}
|
||||
await runCommand(
|
||||
this.globalState,
|
||||
"exchange-keyup",
|
||||
"taler-exchange-keyup",
|
||||
[
|
||||
"-c", this.configFilename,
|
||||
...this.timetravelArgArr,
|
||||
"--revoke",
|
||||
denomPubHash,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.isRunning()) {
|
||||
throw Error("exchange is already running");
|
||||
@ -1540,6 +1560,14 @@ export class WalletCli {
|
||||
throw new OperationFailedError(resp.error);
|
||||
}
|
||||
|
||||
async dumpCoins(): Promise<CoinDumpJson> {
|
||||
const resp = await this.apiRequest("dumpCoins", {});
|
||||
if (resp.type === "response") {
|
||||
return codecForAny().decode(resp.result);
|
||||
}
|
||||
throw new OperationFailedError(resp.error);
|
||||
}
|
||||
|
||||
async addExchange(req: AddExchangeRequest): Promise<void> {
|
||||
const resp = await this.apiRequest("addExchange", req);
|
||||
if (resp.type === "response") {
|
||||
@ -1548,6 +1576,22 @@ export class WalletCli {
|
||||
throw new OperationFailedError(resp.error);
|
||||
}
|
||||
|
||||
async forceUpdateExchange(req: ForceExchangeUpdateRequest): Promise<void> {
|
||||
const resp = await this.apiRequest("forceUpdateExchange", req);
|
||||
if (resp.type === "response") {
|
||||
return;
|
||||
}
|
||||
throw new OperationFailedError(resp.error);
|
||||
}
|
||||
|
||||
async forceRefresh(req: ForceRefreshRequest): Promise<void> {
|
||||
const resp = await this.apiRequest("forceRefresh", req);
|
||||
if (resp.type === "response") {
|
||||
return;
|
||||
}
|
||||
throw new OperationFailedError(resp.error);
|
||||
}
|
||||
|
||||
async listExchanges(): Promise<ExchangesListRespose> {
|
||||
const resp = await this.apiRequest("listExchanges", {});
|
||||
if (resp.type === "response") {
|
||||
|
@ -36,8 +36,9 @@ import {
|
||||
MerchantServiceInterface,
|
||||
BankApi,
|
||||
BankAccessApi,
|
||||
MerchantPrivateApi,
|
||||
} from "./harness";
|
||||
import { AmountString } from "taler-wallet-core";
|
||||
import { AmountString, Duration, PreparePayResultType, ConfirmPayResultType, ContractTerms } from "taler-wallet-core";
|
||||
import { FaultInjectedMerchantService } from "./faultInjection";
|
||||
|
||||
export interface SimpleTestEnvironment {
|
||||
@ -280,3 +281,85 @@ export async function withdrawViaBank(
|
||||
const balApiResp = await wallet.apiRequest("getBalances", {});
|
||||
t.assertTrue(balApiResp.type === "response");
|
||||
}
|
||||
|
||||
export async function applyTimeTravel(
|
||||
timetravelDuration: Duration,
|
||||
s: {
|
||||
exchange?: ExchangeService;
|
||||
merchant?: MerchantService;
|
||||
wallet?: WalletCli;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (s.exchange) {
|
||||
await s.exchange.stop();
|
||||
s.exchange.setTimetravel(timetravelDuration);
|
||||
await s.exchange.start();
|
||||
await s.exchange.pingUntilAvailable();
|
||||
}
|
||||
|
||||
if (s.merchant) {
|
||||
await s.merchant.stop();
|
||||
s.merchant.setTimetravel(timetravelDuration);
|
||||
await s.merchant.start();
|
||||
await s.merchant.pingUntilAvailable();
|
||||
}
|
||||
|
||||
if (s.wallet) {
|
||||
s.wallet.setTimetravel(timetravelDuration);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Make a simple payment and check that it succeeded.
|
||||
*/
|
||||
export async function makeTestPayment(t: GlobalTestState, args: {
|
||||
merchant: MerchantServiceInterface,
|
||||
wallet: WalletCli,
|
||||
order: Partial<ContractTerms>,
|
||||
instance?: string
|
||||
}): Promise<void> {
|
||||
// Set up order.
|
||||
|
||||
const { wallet, merchant } = args;
|
||||
const instance = args.instance ?? "default";
|
||||
|
||||
const orderResp = await MerchantPrivateApi.createOrder(merchant, instance, {
|
||||
order: {
|
||||
summary: "Buy me!",
|
||||
amount: "TESTKUDOS:5",
|
||||
fulfillment_url: "taler://fulfillment-success/thx",
|
||||
},
|
||||
});
|
||||
|
||||
let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
|
||||
orderId: orderResp.order_id,
|
||||
});
|
||||
|
||||
t.assertTrue(orderStatus.order_status === "unpaid");
|
||||
|
||||
// Make wallet pay for the order
|
||||
|
||||
const preparePayResult = await wallet.preparePay({
|
||||
talerPayUri: orderStatus.taler_pay_uri,
|
||||
});
|
||||
|
||||
t.assertTrue(
|
||||
preparePayResult.status === PreparePayResultType.PaymentPossible,
|
||||
);
|
||||
|
||||
const r2 = await wallet.confirmPay({
|
||||
proposalId: preparePayResult.proposalId,
|
||||
});
|
||||
|
||||
t.assertTrue(r2.type === ConfirmPayResultType.Done);
|
||||
|
||||
// Check if payment was successful.
|
||||
|
||||
orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
|
||||
orderId: orderResp.order_id,
|
||||
instance,
|
||||
});
|
||||
|
||||
t.assertTrue(orderStatus.order_status === "paid");
|
||||
}
|
@ -20,14 +20,15 @@
|
||||
import {
|
||||
runTest,
|
||||
GlobalTestState,
|
||||
MerchantPrivateApi,
|
||||
WalletCli,
|
||||
} from "./harness";
|
||||
import { createSimpleTestkudosEnvironment, withdrawViaBank } from "./helpers";
|
||||
import { PreparePayResultType } from "taler-wallet-core";
|
||||
import {
|
||||
createSimpleTestkudosEnvironment,
|
||||
withdrawViaBank,
|
||||
makeTestPayment,
|
||||
} from "./helpers";
|
||||
|
||||
/**
|
||||
* Run test for basic, bank-integrated withdrawal.
|
||||
* Run test for basic, bank-integrated withdrawal and payment.
|
||||
*/
|
||||
runTest(async (t: GlobalTestState) => {
|
||||
// Set up test environment
|
||||
@ -43,45 +44,11 @@ runTest(async (t: GlobalTestState) => {
|
||||
|
||||
await withdrawViaBank(t, { wallet, bank, exchange, amount: "TESTKUDOS:20" });
|
||||
|
||||
// Set up order.
|
||||
const order = {
|
||||
summary: "Buy me!",
|
||||
amount: "TESTKUDOS:5",
|
||||
fulfillment_url: "taler://fulfillment-success/thx",
|
||||
};
|
||||
|
||||
const orderResp = await MerchantPrivateApi.createOrder(merchant, "default", {
|
||||
order: {
|
||||
summary: "Buy me!",
|
||||
amount: "TESTKUDOS:5",
|
||||
fulfillment_url: "taler://fulfillment-success/thx",
|
||||
},
|
||||
});
|
||||
|
||||
let orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
|
||||
orderId: orderResp.order_id,
|
||||
});
|
||||
|
||||
t.assertTrue(orderStatus.order_status === "unpaid");
|
||||
|
||||
// Make wallet pay for the order
|
||||
|
||||
const preparePayResult = await wallet.preparePay({
|
||||
talerPayUri: orderStatus.taler_pay_uri,
|
||||
});
|
||||
|
||||
t.assertTrue(
|
||||
preparePayResult.status === PreparePayResultType.PaymentPossible,
|
||||
);
|
||||
|
||||
const r2 = await wallet.apiRequest("confirmPay", {
|
||||
// FIXME: should be validated, don't cast!
|
||||
proposalId: preparePayResult.proposalId,
|
||||
});
|
||||
t.assertTrue(r2.type === "response");
|
||||
|
||||
// Check if payment was successful.
|
||||
|
||||
orderStatus = await MerchantPrivateApi.queryPrivateOrderStatus(merchant, {
|
||||
orderId: orderResp.order_id,
|
||||
});
|
||||
|
||||
t.assertTrue(orderStatus.order_status === "paid");
|
||||
|
||||
await t.shutdown();
|
||||
await makeTestPayment(t, { wallet, merchant, order });
|
||||
});
|
||||
|
@ -8,7 +8,7 @@ import { IDBFactory, IDBDatabase } from "idb-bridge";
|
||||
* with each major change. When incrementing the major version,
|
||||
* the wallet should import data from the previous version.
|
||||
*/
|
||||
const TALER_DB_NAME = "taler-walletdb-v9";
|
||||
const TALER_DB_NAME = "taler-walletdb-v10";
|
||||
|
||||
/**
|
||||
* Current database minor version, should be incremented
|
||||
|
@ -201,6 +201,7 @@ async function recoupWithdrawCoin(
|
||||
const currency = updatedCoin.currentAmount.currency;
|
||||
updatedCoin.currentAmount = Amounts.getZero(currency);
|
||||
updatedReserve.reserveStatus = ReserveRecordStatus.QUERYING_STATUS;
|
||||
updatedReserve.retryInfo = initRetryInfo();
|
||||
await tx.put(Stores.coins, updatedCoin);
|
||||
await tx.put(Stores.reserves, updatedReserve);
|
||||
await putGroupAsFinished(ws, tx, recoupGroup, coinIdx);
|
||||
@ -253,7 +254,13 @@ async function recoupRefreshCoin(
|
||||
}
|
||||
|
||||
await ws.db.runWithWriteTransaction(
|
||||
[Stores.coins, Stores.reserves, Stores.recoupGroups, Stores.refreshGroups],
|
||||
[
|
||||
Stores.coins,
|
||||
Stores.denominations,
|
||||
Stores.reserves,
|
||||
Stores.recoupGroups,
|
||||
Stores.refreshGroups,
|
||||
],
|
||||
async (tx) => {
|
||||
const recoupGroup = await tx.get(Stores.recoupGroups, recoupGroupId);
|
||||
if (!recoupGroup) {
|
||||
|
@ -74,6 +74,7 @@ import {
|
||||
import {
|
||||
reconcileReserveHistory,
|
||||
summarizeReserveHistory,
|
||||
ReserveHistorySummary,
|
||||
} from "../util/reserveHistoryUtil";
|
||||
import { TransactionHandle } from "../util/query";
|
||||
import { addPaytoQueryParams } from "../util/payto";
|
||||
@ -162,6 +163,7 @@ export async function createReserve(
|
||||
retryInfo: initRetryInfo(),
|
||||
lastError: undefined,
|
||||
currency: req.amount.currency,
|
||||
requestedQuery: false,
|
||||
};
|
||||
|
||||
const reserveHistoryRecord: ReserveHistoryRecord = {
|
||||
@ -285,13 +287,12 @@ export async function forceQueryReserve(
|
||||
// Only force status query where it makes sense
|
||||
switch (reserve.reserveStatus) {
|
||||
case ReserveRecordStatus.DORMANT:
|
||||
case ReserveRecordStatus.WITHDRAWING:
|
||||
case ReserveRecordStatus.QUERYING_STATUS:
|
||||
reserve.reserveStatus = ReserveRecordStatus.QUERYING_STATUS;
|
||||
break;
|
||||
default:
|
||||
reserve.requestedQuery = true;
|
||||
return;
|
||||
}
|
||||
reserve.reserveStatus = ReserveRecordStatus.QUERYING_STATUS;
|
||||
reserve.retryInfo = initRetryInfo();
|
||||
await tx.put(Stores.reserves, reserve);
|
||||
});
|
||||
@ -551,6 +552,7 @@ async function updateReserve(
|
||||
|
||||
const balance = Amounts.parseOrThrow(reserveInfo.balance);
|
||||
const currency = balance.currency;
|
||||
let updateSummary: ReserveHistorySummary | undefined;
|
||||
await ws.db.runWithWriteTransaction(
|
||||
[Stores.reserves, Stores.reserveUpdatedEvents, Stores.reserveHistory],
|
||||
async (tx) => {
|
||||
@ -578,7 +580,7 @@ async function updateReserve(
|
||||
reserveInfo.history,
|
||||
);
|
||||
|
||||
const summary = summarizeReserveHistory(
|
||||
updateSummary = summarizeReserveHistory(
|
||||
reconciled.updatedLocalHistory,
|
||||
currency,
|
||||
);
|
||||
@ -591,16 +593,24 @@ async function updateReserve(
|
||||
reservePub: r.reservePub,
|
||||
timestamp: getTimestampNow(),
|
||||
amountReserveBalance: Amounts.stringify(balance),
|
||||
amountExpected: Amounts.stringify(summary.awaitedReserveAmount),
|
||||
amountExpected: Amounts.stringify(updateSummary.awaitedReserveAmount),
|
||||
newHistoryTransactions,
|
||||
reserveUpdateId,
|
||||
};
|
||||
await tx.put(Stores.reserveUpdatedEvents, reserveUpdate);
|
||||
logger.trace("setting reserve status to 'withdrawing' after query");
|
||||
r.reserveStatus = ReserveRecordStatus.WITHDRAWING;
|
||||
r.retryInfo = initRetryInfo();
|
||||
} else {
|
||||
r.reserveStatus = ReserveRecordStatus.DORMANT;
|
||||
r.retryInfo = initRetryInfo(false);
|
||||
logger.trace("setting reserve status to 'dormant' after query");
|
||||
if (r.requestedQuery) {
|
||||
r.reserveStatus = ReserveRecordStatus.QUERYING_STATUS;
|
||||
r.requestedQuery = false;
|
||||
r.retryInfo = initRetryInfo();
|
||||
} else {
|
||||
r.reserveStatus = ReserveRecordStatus.DORMANT;
|
||||
r.retryInfo = initRetryInfo(false);
|
||||
}
|
||||
}
|
||||
r.lastSuccessfulStatusQuery = getTimestampNow();
|
||||
hist.reserveTransactions = reconciled.updatedLocalHistory;
|
||||
@ -609,7 +619,11 @@ async function updateReserve(
|
||||
await tx.put(Stores.reserveHistory, hist);
|
||||
},
|
||||
);
|
||||
ws.notify({ type: NotificationType.ReserveUpdated });
|
||||
ws.notify({ type: NotificationType.ReserveUpdated, updateSummary });
|
||||
const reserve2 = await ws.db.get(Stores.reserves, reservePub);
|
||||
if (reserve2) {
|
||||
logger.trace(`after db transaction, reserve status is ${reserve2.reserveStatus}`);
|
||||
}
|
||||
return { ready: true };
|
||||
}
|
||||
|
||||
@ -782,6 +796,7 @@ async function depleteReserve(
|
||||
});
|
||||
}
|
||||
}
|
||||
logger.trace("setting reserve status to dormant after depletion");
|
||||
newReserve.reserveStatus = ReserveRecordStatus.DORMANT;
|
||||
newReserve.retryInfo = initRetryInfo(false);
|
||||
|
||||
|
@ -320,6 +320,12 @@ export interface ReserveRecord {
|
||||
|
||||
reserveStatus: ReserveRecordStatus;
|
||||
|
||||
/**
|
||||
* Was a reserve query requested? If so, query again instead
|
||||
* of going into dormant status.
|
||||
*/
|
||||
requestedQuery: boolean;
|
||||
|
||||
/**
|
||||
* Time of the last successful status query.
|
||||
*/
|
||||
|
@ -24,6 +24,7 @@
|
||||
*/
|
||||
import { TalerErrorDetails } from "./walletTypes";
|
||||
import { WithdrawalSource } from "./dbTypes";
|
||||
import { ReserveHistorySummary } from "../util/reserveHistoryUtil";
|
||||
|
||||
export enum NotificationType {
|
||||
CoinWithdrawn = "coin-withdrawn",
|
||||
@ -126,6 +127,7 @@ export interface RefreshRefusedNotification {
|
||||
|
||||
export interface ReserveUpdatedNotification {
|
||||
type: NotificationType.ReserveUpdated;
|
||||
updateSummary?: ReserveHistorySummary;
|
||||
}
|
||||
|
||||
export interface ReserveConfirmedNotification {
|
||||
|
@ -691,6 +691,17 @@ export const codecForAddExchangeRequest = (): Codec<AddExchangeRequest> =>
|
||||
.property("exchangeBaseUrl", codecForString())
|
||||
.build("AddExchangeRequest");
|
||||
|
||||
export interface ForceExchangeUpdateRequest {
|
||||
exchangeBaseUrl: string;
|
||||
}
|
||||
|
||||
export const codecForForceExchangeUpdateRequest = (): Codec<
|
||||
AddExchangeRequest
|
||||
> =>
|
||||
buildCodecForObject<AddExchangeRequest>()
|
||||
.property("exchangeBaseUrl", codecForString())
|
||||
.build("AddExchangeRequest");
|
||||
|
||||
export interface GetExchangeTosRequest {
|
||||
exchangeBaseUrl: string;
|
||||
}
|
||||
@ -870,3 +881,25 @@ export const codecForApplyRefundResponse = (): Codec<ApplyRefundResponse> =>
|
||||
.property("pendingAtExchange", codecForBoolean())
|
||||
.property("proposalId", codecForString())
|
||||
.build("ApplyRefundResponse");
|
||||
|
||||
export interface SetCoinSuspendedRequest {
|
||||
coinPub: string;
|
||||
suspended: boolean;
|
||||
}
|
||||
|
||||
export const codecForSetCoinSuspendedRequest = (): Codec<
|
||||
SetCoinSuspendedRequest
|
||||
> =>
|
||||
buildCodecForObject<SetCoinSuspendedRequest>()
|
||||
.property("coinPub", codecForString())
|
||||
.property("suspended", codecForBoolean())
|
||||
.build("SetCoinSuspendedRequest");
|
||||
|
||||
export interface ForceRefreshRequest {
|
||||
coinPubList: string[];
|
||||
}
|
||||
|
||||
export const codecForForceRefreshRequest = (): Codec<ForceRefreshRequest> =>
|
||||
buildCodecForObject<ForceRefreshRequest>()
|
||||
.property("coinPubList", codecForList(codecForString()))
|
||||
.build("ForceRefreshRequest");
|
||||
|
@ -90,6 +90,9 @@ import {
|
||||
withdrawTestBalanceDefaults,
|
||||
codecForWithdrawTestBalance,
|
||||
codecForTestPayArgs,
|
||||
codecForSetCoinSuspendedRequest,
|
||||
codecForForceExchangeUpdateRequest,
|
||||
codecForForceRefreshRequest,
|
||||
} from "./types/walletTypes";
|
||||
import { Logger } from "./util/logging";
|
||||
|
||||
@ -110,7 +113,11 @@ import {
|
||||
|
||||
import { InternalWalletState } from "./operations/state";
|
||||
import { createReserve } from "./operations/reserves";
|
||||
import { processRefreshGroup, createRefreshGroup, autoRefresh } from "./operations/refresh";
|
||||
import {
|
||||
processRefreshGroup,
|
||||
createRefreshGroup,
|
||||
autoRefresh,
|
||||
} from "./operations/refresh";
|
||||
import { processWithdrawGroup } from "./operations/withdraw";
|
||||
import { getPendingOperations } from "./operations/pending";
|
||||
import { getBalances } from "./operations/balance";
|
||||
@ -268,7 +275,7 @@ export class Wallet {
|
||||
await processRecoupGroup(this.ws, pending.recoupGroupId, forceNow);
|
||||
break;
|
||||
case PendingOperationType.ExchangeCheckRefresh:
|
||||
await autoRefresh(this.ws, pending.exchangeBaseUrl)
|
||||
await autoRefresh(this.ws, pending.exchangeBaseUrl);
|
||||
break;
|
||||
default:
|
||||
assertUnreachable(pending);
|
||||
@ -371,7 +378,8 @@ export class Wallet {
|
||||
}
|
||||
|
||||
private async runRetryLoopImpl(): Promise<void> {
|
||||
while (!this.stopped) {
|
||||
let iteration = 0;
|
||||
for (; !this.stopped; iteration++) {
|
||||
const pending = await this.getPendingOperations({ onlyDue: true });
|
||||
let numDueAndLive = 0;
|
||||
for (const p of pending.pendingOperations) {
|
||||
@ -379,7 +387,9 @@ export class Wallet {
|
||||
numDueAndLive++;
|
||||
}
|
||||
}
|
||||
if (numDueAndLive === 0) {
|
||||
// Make sure that we run tasks that don't give lifeness at least
|
||||
// one time.
|
||||
if (iteration !== 0 && numDueAndLive === 0) {
|
||||
const allPending = await this.getPendingOperations({ onlyDue: false });
|
||||
let numPending = 0;
|
||||
let numGivingLiveness = 0;
|
||||
@ -406,11 +416,12 @@ export class Wallet {
|
||||
numPending,
|
||||
});
|
||||
await Promise.race([timeout, this.latch.wait()]);
|
||||
logger.trace("timeout done");
|
||||
} else {
|
||||
// FIXME: maybe be a bit smarter about executing these
|
||||
// operations in parallel?
|
||||
logger.trace(`running ${pending.pendingOperations.length} pending operations`);
|
||||
logger.trace(
|
||||
`running ${pending.pendingOperations.length} pending operations`,
|
||||
);
|
||||
for (const p of pending.pendingOperations) {
|
||||
try {
|
||||
await this.processOnePendingOperation(p);
|
||||
@ -985,6 +996,11 @@ export class Wallet {
|
||||
await this.updateExchangeFromUrl(req.exchangeBaseUrl);
|
||||
return {};
|
||||
}
|
||||
case "forceUpdateExchange": {
|
||||
const req = codecForForceExchangeUpdateRequest().decode(payload);
|
||||
await this.updateExchangeFromUrl(req.exchangeBaseUrl, true);
|
||||
return {};
|
||||
}
|
||||
case "listExchanges": {
|
||||
return await this.getExchanges();
|
||||
}
|
||||
@ -1054,6 +1070,32 @@ export class Wallet {
|
||||
const req = codecForConfirmPayRequest().decode(payload);
|
||||
return await this.confirmPay(req.proposalId, req.sessionId);
|
||||
}
|
||||
case "dumpCoins": {
|
||||
return await this.dumpCoins();
|
||||
}
|
||||
case "setCoinSuspended": {
|
||||
const req = codecForSetCoinSuspendedRequest().decode(payload);
|
||||
await this.setCoinSuspended(req.coinPub, req.suspended);
|
||||
return {};
|
||||
}
|
||||
case "forceRefresh": {
|
||||
const req = codecForForceRefreshRequest().decode(payload);
|
||||
const coinPubs = req.coinPubList.map((x) => ({ coinPub: x }));
|
||||
const refreshGroupId = await this.db.runWithWriteTransaction(
|
||||
[Stores.refreshGroups, Stores.denominations, Stores.coins],
|
||||
async (tx) => {
|
||||
return await createRefreshGroup(
|
||||
this.ws,
|
||||
tx,
|
||||
coinPubs,
|
||||
RefreshReason.Manual,
|
||||
);
|
||||
},
|
||||
);
|
||||
return {
|
||||
refreshGroupId,
|
||||
};
|
||||
}
|
||||
}
|
||||
throw OperationFailedError.fromCode(
|
||||
TalerErrorCode.WALLET_CORE_API_OPERATION_UNKNOWN,
|
||||
|
Loading…
Reference in New Issue
Block a user