aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFlorian Dold <florian.dold@gmail.com>2019-12-16 12:53:22 +0100
committerFlorian Dold <florian.dold@gmail.com>2019-12-16 12:53:22 +0100
commitfa4621e70c48500a372504eb8ae9b9481531c555 (patch)
tree50c457c8c2133dfec32cb465e1b3902ce88fb209
parent1b9c5855a8afb6833ff7a706f5bed5650e1191ad (diff)
history events WIP
-rw-r--r--src/operations/exchanges.ts75
-rw-r--r--src/operations/history.ts337
-rw-r--r--src/operations/pay.ts1
-rw-r--r--src/operations/pending.ts18
-rw-r--r--src/operations/refresh.ts2
-rw-r--r--src/operations/refund.ts120
-rw-r--r--src/operations/reserves.ts44
-rw-r--r--src/operations/tip.ts9
-rw-r--r--src/operations/withdraw.ts2
-rw-r--r--src/types/dbTypes.ts69
-rw-r--r--src/types/history.ts46
-rw-r--r--src/types/pending.ts19
-rw-r--r--src/types/talerTypes.ts30
-rw-r--r--src/util/amounts.ts7
-rw-r--r--src/util/codec-test.ts26
-rw-r--r--src/util/codec.ts159
-rw-r--r--src/util/helpers.ts10
-rw-r--r--src/util/query.ts11
-rw-r--r--src/wallet.ts82
-rw-r--r--tsconfig.json2
20 files changed, 793 insertions, 276 deletions
diff --git a/src/operations/exchanges.ts b/src/operations/exchanges.ts
index 6c4c1aa0c..fc1a50f00 100644
--- a/src/operations/exchanges.ts
+++ b/src/operations/exchanges.ts
@@ -25,15 +25,15 @@ import {
DenominationRecord,
DenominationStatus,
WireFee,
+ ExchangeUpdateReason,
+ ExchangeUpdatedEventRecord,
} from "../types/dbTypes";
import {
canonicalizeBaseUrl,
extractTalerStamp,
extractTalerStampOrThrow,
} from "../util/helpers";
-import {
- Database
-} from "../util/query";
+import { Database } from "../util/query";
import * as Amounts from "../util/amounts";
import { parsePaytoUri } from "../util/payto";
import {
@@ -78,7 +78,7 @@ async function setExchangeError(
exchange.lastError = err;
return exchange;
};
- await ws.db.mutate( Stores.exchanges, baseUrl, mut);
+ await ws.db.mutate(Stores.exchanges, baseUrl, mut);
}
/**
@@ -91,12 +91,9 @@ async function updateExchangeWithKeys(
ws: InternalWalletState,
baseUrl: string,
): Promise<void> {
- const existingExchangeRecord = await ws.db.get(
- Stores.exchanges,
- baseUrl,
- );
+ const existingExchangeRecord = await ws.db.get(Stores.exchanges, baseUrl);
- if (existingExchangeRecord?.updateStatus != ExchangeUpdateStatus.FETCH_KEYS) {
+ if (existingExchangeRecord?.updateStatus != ExchangeUpdateStatus.FetchKeys) {
return;
}
const keysUrl = new URL("keys", baseUrl);
@@ -194,7 +191,7 @@ async function updateExchangeWithKeys(
masterPublicKey: exchangeKeysJson.master_public_key,
protocolVersion: protocolVersion,
};
- r.updateStatus = ExchangeUpdateStatus.FETCH_WIRE;
+ r.updateStatus = ExchangeUpdateStatus.FetchWire;
r.lastError = undefined;
await tx.put(Stores.exchanges, r);
@@ -213,6 +210,38 @@ async function updateExchangeWithKeys(
);
}
+async function updateExchangeFinalize(
+ ws: InternalWalletState,
+ exchangeBaseUrl: string,
+) {
+ const exchange = await ws.db.get(Stores.exchanges, exchangeBaseUrl);
+ if (!exchange) {
+ return;
+ }
+ if (exchange.updateStatus != ExchangeUpdateStatus.FinalizeUpdate) {
+ return;
+ }
+ await ws.db.runWithWriteTransaction(
+ [Stores.exchanges, Stores.exchangeUpdatedEvents],
+ async tx => {
+ const r = await tx.get(Stores.exchanges, exchangeBaseUrl);
+ if (!r) {
+ return;
+ }
+ if (r.updateStatus != ExchangeUpdateStatus.FinalizeUpdate) {
+ return;
+ }
+ r.updateStatus = ExchangeUpdateStatus.Finished;
+ await tx.put(Stores.exchanges, r);
+ const updateEvent: ExchangeUpdatedEventRecord = {
+ exchangeBaseUrl: exchange.baseUrl,
+ timestamp: getTimestampNow(),
+ };
+ await tx.put(Stores.exchangeUpdatedEvents, updateEvent);
+ },
+ );
+}
+
async function updateExchangeWithTermsOfService(
ws: InternalWalletState,
exchangeBaseUrl: string,
@@ -221,7 +250,7 @@ async function updateExchangeWithTermsOfService(
if (!exchange) {
return;
}
- if (exchange.updateStatus != ExchangeUpdateStatus.FETCH_TERMS) {
+ if (exchange.updateStatus != ExchangeUpdateStatus.FetchTerms) {
return;
}
const reqUrl = new URL("terms", exchangeBaseUrl);
@@ -243,12 +272,12 @@ async function updateExchangeWithTermsOfService(
if (!r) {
return;
}
- if (r.updateStatus != ExchangeUpdateStatus.FETCH_TERMS) {
+ if (r.updateStatus != ExchangeUpdateStatus.FetchTerms) {
return;
}
r.termsOfServiceText = tosText;
r.termsOfServiceLastEtag = tosEtag;
- r.updateStatus = ExchangeUpdateStatus.FINISHED;
+ r.updateStatus = ExchangeUpdateStatus.FinalizeUpdate;
await tx.put(Stores.exchanges, r);
});
}
@@ -282,7 +311,7 @@ async function updateExchangeWithWireInfo(
if (!exchange) {
return;
}
- if (exchange.updateStatus != ExchangeUpdateStatus.FETCH_WIRE) {
+ if (exchange.updateStatus != ExchangeUpdateStatus.FetchWire) {
return;
}
const details = exchange.details;
@@ -349,14 +378,14 @@ async function updateExchangeWithWireInfo(
if (!r) {
return;
}
- if (r.updateStatus != ExchangeUpdateStatus.FETCH_WIRE) {
+ if (r.updateStatus != ExchangeUpdateStatus.FetchWire) {
return;
}
r.wireInfo = {
accounts: wireInfo.accounts,
feesForType: feesForType,
};
- r.updateStatus = ExchangeUpdateStatus.FETCH_TERMS;
+ r.updateStatus = ExchangeUpdateStatus.FetchTerms;
r.lastError = undefined;
await tx.put(Stores.exchanges, r);
});
@@ -390,12 +419,13 @@ async function updateExchangeFromUrlImpl(
const r = await ws.db.get(Stores.exchanges, baseUrl);
if (!r) {
const newExchangeRecord: ExchangeRecord = {
+ builtIn: false,
baseUrl: baseUrl,
details: undefined,
wireInfo: undefined,
- updateStatus: ExchangeUpdateStatus.FETCH_KEYS,
+ updateStatus: ExchangeUpdateStatus.FetchKeys,
updateStarted: now,
- updateReason: "initial",
+ updateReason: ExchangeUpdateReason.Initial,
timestampAdded: getTimestampNow(),
termsOfServiceAcceptedEtag: undefined,
termsOfServiceAcceptedTimestamp: undefined,
@@ -409,14 +439,14 @@ async function updateExchangeFromUrlImpl(
if (!rec) {
return;
}
- if (rec.updateStatus != ExchangeUpdateStatus.FETCH_KEYS && !forceNow) {
+ if (rec.updateStatus != ExchangeUpdateStatus.FetchKeys && !forceNow) {
return;
}
- if (rec.updateStatus != ExchangeUpdateStatus.FETCH_KEYS && forceNow) {
- rec.updateReason = "forced";
+ if (rec.updateStatus != ExchangeUpdateStatus.FetchKeys && forceNow) {
+ rec.updateReason = ExchangeUpdateReason.Forced;
}
rec.updateStarted = now;
- rec.updateStatus = ExchangeUpdateStatus.FETCH_KEYS;
+ rec.updateStatus = ExchangeUpdateStatus.FetchKeys;
rec.lastError = undefined;
t.put(Stores.exchanges, rec);
});
@@ -425,6 +455,7 @@ async function updateExchangeFromUrlImpl(
await updateExchangeWithKeys(ws, baseUrl);
await updateExchangeWithWireInfo(ws, baseUrl);
await updateExchangeWithTermsOfService(ws, baseUrl);
+ await updateExchangeFinalize(ws, baseUrl);
const updatedExchange = await ws.db.get(Stores.exchanges, baseUrl);
diff --git a/src/operations/history.ts b/src/operations/history.ts
index 8b225ea07..7e985d218 100644
--- a/src/operations/history.ts
+++ b/src/operations/history.ts
@@ -18,10 +18,132 @@
* Imports.
*/
import { InternalWalletState } from "./state";
-import { Stores, TipRecord } from "../types/dbTypes";
+import {
+ Stores,
+ TipRecord,
+ ProposalStatus,
+ ProposalRecord,
+} from "../types/dbTypes";
import * as Amounts from "../util/amounts";
import { AmountJson } from "../util/amounts";
-import { HistoryQuery, HistoryEvent, HistoryEventType } from "../types/history";
+import {
+ HistoryQuery,
+ HistoryEvent,
+ HistoryEventType,
+ OrderShortInfo,
+ ReserveType,
+ ReserveCreationDetail,
+} from "../types/history";
+import { assertUnreachable } from "../util/assertUnreachable";
+import { TransactionHandle, Store } from "../util/query";
+import { ReserveTransactionType } from "../types/ReserveTransaction";
+
+/**
+ * Create an event ID from the type and the primary key for the event.
+ */
+function makeEventId(type: HistoryEventType, ...args: string[]) {
+ return type + ";" + args.map(x => encodeURIComponent(x)).join(";");
+}
+
+function getOrderShortInfo(
+ proposal: ProposalRecord,
+): OrderShortInfo | undefined {
+ const download = proposal.download;
+ if (!download) {
+ return undefined;
+ }
+ return {
+ amount: download.contractTerms.amount,
+ orderId: download.contractTerms.order_id,
+ merchantBaseUrl: download.contractTerms.merchant_base_url,
+ proposalId: proposal.proposalId,
+ summary: download.contractTerms.summary || "",
+ };
+}
+
+
+async function collectProposalHistory(
+ tx: TransactionHandle,
+ history: HistoryEvent[],
+ historyQuery?: HistoryQuery,
+) {
+ tx.iter(Stores.proposals).forEachAsync(async proposal => {
+ const status = proposal.proposalStatus;
+ switch (status) {
+ case ProposalStatus.ACCEPTED:
+ {
+ const shortInfo = getOrderShortInfo(proposal);
+ if (!shortInfo) {
+ break;
+ }
+ history.push({
+ type: HistoryEventType.OrderAccepted,
+ eventId: makeEventId(
+ HistoryEventType.OrderAccepted,
+ proposal.proposalId,
+ ),
+ orderShortInfo: shortInfo,
+ timestamp: proposal.timestamp,
+ });
+ }
+ break;
+ case ProposalStatus.DOWNLOADING:
+ case ProposalStatus.PROPOSED:
+ // no history event needed
+ break;
+ case ProposalStatus.REJECTED:
+ {
+ const shortInfo = getOrderShortInfo(proposal);
+ if (!shortInfo) {
+ break;
+ }
+ history.push({
+ type: HistoryEventType.OrderRefused,
+ eventId: makeEventId(
+ HistoryEventType.OrderRefused,
+ proposal.proposalId,
+ ),
+ orderShortInfo: shortInfo,
+ timestamp: proposal.timestamp,
+ });
+ }
+ break;
+ case ProposalStatus.REPURCHASE:
+ {
+ const alreadyPaidProposal = await tx.get(
+ Stores.proposals,
+ proposal.repurchaseProposalId,
+ );
+ if (!alreadyPaidProposal) {
+ break;
+ }
+ const alreadyPaidOrderShortInfo = getOrderShortInfo(
+ alreadyPaidProposal,
+ );
+ if (!alreadyPaidOrderShortInfo) {
+ break;
+ }
+ const newOrderShortInfo = getOrderShortInfo(proposal);
+ if (!newOrderShortInfo) {
+ break;
+ }
+ history.push({
+ type: HistoryEventType.OrderRedirected,
+ eventId: makeEventId(
+ HistoryEventType.OrderRedirected,
+ proposal.proposalId,
+ ),
+ alreadyPaidOrderShortInfo,
+ newOrderShortInfo,
+ timestamp: proposal.timestamp,
+ });
+ }
+ break;
+ default:
+ assertUnreachable(status);
+ }
+ });
+}
/**
* Retrive the full event history for this wallet.
@@ -40,19 +162,222 @@ export async function getHistory(
await ws.db.runWithReadTransaction(
[
Stores.currencies,
- Stores.coins,
- Stores.denominations,
Stores.exchanges,
+ Stores.exchangeUpdatedEvents,
Stores.proposals,
Stores.purchases,
Stores.refreshGroups,
Stores.reserves,
Stores.tips,
Stores.withdrawalSession,
+ Stores.payEvents,
+ Stores.refundEvents,
+ Stores.reserveUpdatedEvents,
],
async tx => {
- // FIXME: implement new history schema!!
- }
+ tx.iter(Stores.exchanges).forEach(exchange => {
+ history.push({
+ type: HistoryEventType.ExchangeAdded,
+ builtIn: false,
+ eventId: makeEventId(
+ HistoryEventType.ExchangeAdded,
+ exchange.baseUrl,
+ ),
+ exchangeBaseUrl: exchange.baseUrl,
+ timestamp: exchange.timestampAdded,
+ });
+ });
+
+ tx.iter(Stores.exchangeUpdatedEvents).forEach(eu => {
+ history.push({
+ type: HistoryEventType.ExchangeUpdated,
+ eventId: makeEventId(
+ HistoryEventType.ExchangeUpdated,
+ eu.exchangeBaseUrl,
+ ),
+ exchangeBaseUrl: eu.exchangeBaseUrl,
+ timestamp: eu.timestamp,
+ });
+ });
+
+ tx.iter(Stores.withdrawalSession).forEach(wsr => {
+ if (wsr.finishTimestamp) {
+ history.push({
+ type: HistoryEventType.Withdrawn,
+ withdrawSessionId: wsr.withdrawSessionId,
+ eventId: makeEventId(
+ HistoryEventType.Withdrawn,
+ wsr.withdrawSessionId,
+ ),
+ amountWithdrawnEffective: Amounts.toString(wsr.totalCoinValue),
+ amountWithdrawnRaw: Amounts.toString(wsr.rawWithdrawalAmount),
+ exchangeBaseUrl: wsr.exchangeBaseUrl,
+ timestamp: wsr.finishTimestamp,
+ });
+ }
+ });
+
+ await collectProposalHistory(tx, history, historyQuery);
+
+ await tx.iter(Stores.payEvents).forEachAsync(async (pe) => {
+ const proposal = await tx.get(Stores.proposals, pe.proposalId);
+ if (!proposal) {
+ return;
+ }
+ const orderShortInfo = getOrderShortInfo(proposal);
+ if (!orderShortInfo) {
+ return;
+ }
+ history.push({
+ type: HistoryEventType.PaymentSent,
+ eventId: makeEventId(HistoryEventType.PaymentSent, pe.proposalId),
+ orderShortInfo,
+ replay: pe.isReplay,
+ sessionId: pe.sessionId,
+ timestamp: pe.timestamp,
+ });
+ });
+
+ await tx.iter(Stores.refreshGroups).forEachAsync(async (rg) => {
+ if (!rg.finishedTimestamp) {
+ return;
+ }
+ let numInputCoins = 0;
+ let numRefreshedInputCoins = 0;
+ let numOutputCoins = 0;
+ const amountsRaw: AmountJson[] = [];
+ const amountsEffective: AmountJson[] = [];
+ for (let i = 0; i < rg.refreshSessionPerCoin.length; i++) {
+ const session = rg.refreshSessionPerCoin[i];
+ numInputCoins++;
+ if (session) {
+ numRefreshedInputCoins++;
+ amountsRaw.push(session.valueWithFee);
+ amountsEffective.push(session.valueOutput);
+ numOutputCoins += session.newDenoms.length;
+ } else {
+ const c = await tx.get(Stores.coins, rg.oldCoinPubs[i]);
+ if (!c) {
+ continue;
+ }
+ amountsRaw.push(c.currentAmount);
+ }
+ }
+ let amountRefreshedRaw = Amounts.sum(amountsRaw).amount;
+ let amountRefreshedEffective: AmountJson;
+ if (amountsEffective.length == 0) {
+ amountRefreshedEffective = Amounts.getZero(amountRefreshedRaw.currency);
+ } else {
+ amountRefreshedEffective = Amounts.sum(amountsEffective).amount;
+ }
+ history.push({
+ type: HistoryEventType.Refreshed,
+ refreshGroupId: rg.refreshGroupId,
+ eventId: makeEventId(HistoryEventType.Refreshed, rg.refreshGroupId),
+ timestamp: rg.finishedTimestamp,
+ refreshReason: rg.reason,
+ amountRefreshedEffective: Amounts.toString(amountRefreshedEffective),
+ amountRefreshedRaw: Amounts.toString(amountRefreshedRaw),
+ numInputCoins,
+ numOutputCoins,
+ numRefreshedInputCoins,
+ });
+ });
+
+ tx.iter(Stores.reserveUpdatedEvents).forEachAsync(async (ru) => {
+ const reserve = await tx.get(Stores.reserves, ru.reservePub);
+ if (!reserve) {
+ return;
+ }
+ let reserveCreationDetail: ReserveCreationDetail;
+ if (reserve.bankWithdrawStatusUrl) {
+ reserveCreationDetail = {
+ type: ReserveType.TalerBankWithdraw,
+ bankUrl: reserve.bankWithdrawStatusUrl,
+ }
+ } else {
+ reserveCreationDetail = {
+ type: ReserveType.Manual,
+ }
+ }
+ history.push({
+ type: HistoryEventType.ReserveBalanceUpdated,
+ eventId: makeEventId(HistoryEventType.ReserveBalanceUpdated, ru.reserveUpdateId),
+ amountExpected: ru.amountExpected,
+ amountReserveBalance: ru.amountReserveBalance,
+ timestamp: reserve.created,
+ newHistoryTransactions: ru.newHistoryTransactions,
+ reserveShortInfo: {
+ exchangeBaseUrl: reserve.exchangeBaseUrl,
+ reserveCreationDetail,
+ reservePub: reserve.reservePub,
+ }
+ });
+ });
+
+ tx.iter(Stores.tips).forEach((tip) => {
+ if (tip.acceptedTimestamp) {
+ history.push({
+ type: HistoryEventType.TipAccepted,
+ eventId: makeEventId(HistoryEventType.TipAccepted, tip.tipId),
+ timestamp: tip.acceptedTimestamp,
+ tipId: tip.tipId,
+ tipAmount: Amounts.toString(tip.amount),
+ });
+ }
+ });
+
+ tx.iter(Stores.refundEvents).forEachAsync(async (re) => {
+ const proposal = await tx.get(Stores.proposals, re.proposalId);
+ if (!proposal) {
+ return;
+ }
+ const purchase = await tx.get(Stores.purchases, re.proposalId);
+ if (!purchase) {
+ return;
+ }
+ const orderShortInfo = getOrderShortInfo(proposal);
+ if (!orderShortInfo) {
+ return;
+ }
+ const purchaseAmount = Amounts.parseOrThrow(purchase.contractTerms.amount);
+ let amountRefundedRaw = Amounts.getZero(purchaseAmount.currency);
+ let amountRefundedInvalid = Amounts.getZero(purchaseAmount.currency);
+ let amountRefundedEffective = Amounts.getZero(purchaseAmount.currency);
+ Object.keys(purchase.refundState.refundsDone).forEach((x, i) => {
+ const r = purchase.refundState.refundsDone[x];
+ if (r.refundGroupId !== re.refundGroupId) {
+ return;
+ }
+ const refundAmount = Amounts.parseOrThrow(r.perm.refund_amount);
+ const refundFee = Amounts.parseOrThrow(r.perm.refund_fee);
+ amountRefundedRaw = Amounts.add(amountRefundedRaw, refundAmount).amount;
+ amountRefundedEffective = Amounts.add(amountRefundedEffective, refundAmount).amount;
+ amountRefundedEffective = Amounts.sub(amountRefundedEffective, refundFee).amount;
+ });
+ Object.keys(purchase.refundState.refundsFailed).forEach((x, i) => {
+ const r = purchase.refundState.refundsFailed[x];
+ if (r.refundGroupId !== re.refundGroupId) {
+ return;
+ }
+ const ra = Amounts.parseOrThrow(r.perm.refund_amount);
+ const refundFee = Amounts.parseOrThrow(r.perm.refund_fee);
+ amountRefundedRaw = Amounts.add(amountRefundedRaw, ra).amount;
+ amountRefundedInvalid = Amounts.add(amountRefundedInvalid, ra).amount;
+ amountRefundedEffective = Amounts.sub(amountRefundedEffective, refundFee).amount;
+ });
+ history.push({
+ type: HistoryEventType.Refund,
+ eventId: makeEventId(HistoryEventType.Refund, re.refundGroupId),
+ refundGroupId: re.refundGroupId,
+ orderShortInfo,
+ timestamp: re.timestamp,
+ amountRefundedEffective: Amounts.toString(amountRefundedEffective),
+ amountRefundedRaw: Amounts.toString(amountRefundedRaw),
+ amountRefundedInvalid: Amounts.toString(amountRefundedInvalid),
+ });
+ });
+ },
);
history.sort((h1, h2) => Math.sign(h1.timestamp.t_ms - h2.timestamp.t_ms));
diff --git a/src/operations/pay.ts b/src/operations/pay.ts
index 363688dbd..664524695 100644
--- a/src/operations/pay.ts
+++ b/src/operations/pay.ts
@@ -755,6 +755,7 @@ export async function submitPay(
proposalId,
sessionId,
timestamp: now,
+ isReplay: !isFirst,
};
await tx.put(Stores.payEvents, payEvent);
},
diff --git a/src/operations/pending.ts b/src/operations/pending.ts
index b9b2c664e..252c9e98a 100644
--- a/src/operations/pending.ts
+++ b/src/operations/pending.ts
@@ -54,7 +54,7 @@ async function gatherExchangePending(
}
await tx.iter(Stores.exchanges).forEach(e => {
switch (e.updateStatus) {
- case ExchangeUpdateStatus.FINISHED:
+ case ExchangeUpdateStatus.Finished:
if (e.lastError) {
resp.pendingOperations.push({
type: PendingOperationType.Bug,
@@ -89,7 +89,7 @@ async function gatherExchangePending(
});
}
break;
- case ExchangeUpdateStatus.FETCH_KEYS:
+ case ExchangeUpdateStatus.FetchKeys:
resp.pendingOperations.push({
type: PendingOperationType.ExchangeUpdate,
givesLifeness: false,
@@ -99,7 +99,7 @@ async function gatherExchangePending(
reason: e.updateReason || "unknown",
});
break;
- case ExchangeUpdateStatus.FETCH_WIRE:
+ case ExchangeUpdateStatus.FetchWire:
resp.pendingOperations.push({
type: PendingOperationType.ExchangeUpdate,
givesLifeness: false,
@@ -109,6 +109,16 @@ async function gatherExchangePending(
reason: e.updateReason || "unknown",
});
break;
+ case ExchangeUpdateStatus.FinalizeUpdate:
+ resp.pendingOperations.push({
+ type: PendingOperationType.ExchangeUpdate,
+ givesLifeness: false,
+ stage: "finalize-update",
+ exchangeBaseUrl: e.baseUrl,
+ lastError: e.lastError,
+ reason: e.updateReason || "unknown",
+ });
+ break;
default:
resp.pendingOperations.push({
type: PendingOperationType.Bug,
@@ -311,7 +321,7 @@ async function gatherTipPending(
if (onlyDue && tip.retryInfo.nextRetry.t_ms > now.t_ms) {
return;
}
- if (tip.accepted) {
+ if (tip.acceptedTimestamp) {
resp.pendingOperations.push({
type: PendingOperationType.TipPickup,
givesLifeness: true,
diff --git a/src/operations/refresh.ts b/src/operations/refresh.ts
index be23a5bb0..d9a080bd8 100644
--- a/src/operations/refresh.ts
+++ b/src/operations/refresh.ts
@@ -548,7 +548,7 @@ export async function createRefreshGroup(
finishedTimestamp: undefined,
finishedPerCoin: oldCoinPubs.map(x => false),
lastError: undefined,
- lastErrorPerCoin: oldCoinPubs.map(x => undefined),
+ lastErrorPerCoin: {},
oldCoinPubs: oldCoinPubs.map(x => x.coinPub),
reason,
refreshGroupId,
diff --git a/src/operations/refund.ts b/src/operations/refund.ts
index a2b4dbe24..589418571 100644
--- a/src/operations/refund.ts
+++ b/src/operations/refund.ts
@@ -28,6 +28,7 @@ import {
OperationError,
getTimestampNow,
RefreshReason,
+ CoinPublicKey,
} from "../types/walletTypes";
import {
Stores,
@@ -36,6 +37,7 @@ import {
CoinStatus,
RefundReason,
RefundEventRecord,
+ RefundInfo,
} from "../types/dbTypes";
import { NotificationType } from "../types/notifications";
import { parseRefundUri } from "../util/taleruri";
@@ -214,13 +216,6 @@ export async function acceptRefundResponse(
timestampQueried: now,
reason,
});
-
- const refundEvent: RefundEventRecord = {
- proposalId,
- refundGroupId,
- timestamp: now,
- };
- await tx.put(Stores.refundEvents, refundEvent);
}
await tx.put(Stores.purchases, p);
@@ -406,6 +401,9 @@ async function processPurchaseApplyRefundImpl(
console.log("no pending refunds");
return;
}
+
+ const newRefundsDone: { [sig: string]: RefundInfo } = {};
+ const newRefundsFailed: { [sig: string]: RefundInfo } = {};
for (const pk of pendingKeys) {
const info = purchase.refundState.refundsPending[pk];
const perm = info.perm;
@@ -424,13 +422,13 @@ async function processPurchaseApplyRefundImpl(
const reqUrl = new URL("refund", exchangeUrl);
const resp = await ws.http.postJson(reqUrl.href, req);
console.log("sent refund permission");
- let refundGone = false;
switch (resp.status) {
case HttpResponseStatus.Ok:
+ newRefundsDone[pk] = info;
break;
case HttpResponseStatus.Gone:
// We're too late, refund is expired.
- refundGone = true;
+ newRefundsFailed[pk] = info;
break;
default:
let body: string | null = null;
@@ -446,53 +444,89 @@ async function processPurchaseApplyRefundImpl(
},
});
}
+ }
+ let allRefundsProcessed = false;
+ await ws.db.runWithWriteTransaction(
+ [Stores.purchases, Stores.coins, Stores.refreshGroups, Stores.refundEvents],
+ async tx => {
+ const p = await tx.get(Stores.purchases, proposalId);
+ if (!p) {
+ return;
+ }
- let allRefundsProcessed = false;
+ // Groups that failed/succeeded
+ let groups: { [refundGroupId: string]: boolean } = {};
- await ws.db.runWithWriteTransaction(
- [Stores.purchases, Stores.coins, Stores.refreshGroups],
- async tx => {
- const p = await tx.get(Stores.purchases, proposalId);
- if (!p) {
- return;
- }
- if (p.refundState.refundsPending[pk]) {
- if (refundGone) {
- p.refundState.refundsFailed[pk] = p.refundState.refundsPending[pk];
- } else {
- p.refundState.refundsDone[pk] = p.refundState.refundsPending[pk];
- }
- delete p.refundState.refundsPending[pk];
- }
- if (Object.keys(p.refundState.refundsPending).length === 0) {
- p.refundStatusRetryInfo = initRetryInfo();
- p.lastRefundStatusError = undefined;
- allRefundsProcessed = true;
- }
- await tx.put(Stores.purchases, p);
+ // Avoid duplicates
+ const refreshCoinsMap: { [coinPub: string]: CoinPublicKey } = {};
+
+ const modCoin = async (perm: MerchantRefundPermission) => {
const c = await tx.get(Stores.coins, perm.coin_pub);
if (!c) {
console.warn("coin not found, can't apply refund");
return;
}
+ refreshCoinsMap[c.coinPub] = { coinPub: c.coinPub };
const refundAmount = Amounts.parseOrThrow(perm.refund_amount);
const refundFee = Amounts.parseOrThrow(perm.refund_fee);
c.status = CoinStatus.Dormant;
c.currentAmount = Amounts.add(c.currentAmount, refundAmount).amount;
c.currentAmount = Amounts.sub(c.currentAmount, refundFee).amount;
await tx.put(Stores.coins, c);
- await createRefreshGroup(
- tx,
- [{ coinPub: perm.coin_pub }],
- RefreshReason.Refund,
- );
- },
- );
- if (allRefundsProcessed) {
- ws.notify({
- type: NotificationType.RefundFinished,
- });
- }
+ };
+
+ for (const pk of Object.keys(newRefundsFailed)) {
+ const r = newRefundsFailed[pk];
+ groups[r.refundGroupId] = true;
+ delete p.refundState.refundsPending[pk];
+ p.refundState.refundsFailed[pk] = r;
+ await modCoin(r.perm);
+ }
+
+ for (const pk of Object.keys(newRefundsDone)) {
+ const r = newRefundsDone[pk];
+ groups[r.refundGroupId] = true;
+ delete p.refundState.refundsPending[pk];
+ p.refundState.refundsDone[pk] = r;
+ await modCoin(r.perm);
+ }
+
+ const now = getTimestampNow();
+ for (const g of Object.keys(groups)) {
+ let groupDone = true;
+ for (const pk of Object.keys(p.refundState.refundsPending)) {
+ const r = p.refundState.refundsPending[pk];
+ if (r.refundGroupId == g) {
+ groupDone = false;
+ }
+ }
+ if (groupDone) {
+ const refundEvent: RefundEventRecord = {
+ proposalId,
+ refundGroupId: g,
+ timestamp: now,
+ }
+ await tx.put(Stores.refundEvents, refundEvent);
+ }
+ }
+
+ if (Object.keys(p.refundState.refundsPending).length === 0) {
+ p.refundStatusRetryInfo = initRetryInfo();
+ p.lastRefundStatusError = undefined;
+ allRefundsProcessed = true;
+ }
+ await tx.put(Stores.purchases, p);
+ await createRefreshGroup(
+ tx,
+ Object.values(refreshCoinsMap),
+ RefreshReason.Refund,
+ );
+ },
+ );
+ if (allRefundsProcessed) {
+ ws.notify({
+ type: NotificationType.RefundFinished,
+ });
}
ws.notify({
diff --git a/src/operations/reserves.ts b/src/operations/reserves.ts
index 559d3ab08..56e9c25d6 100644
--- a/src/operations/reserves.ts
+++ b/src/operations/reserves.ts
@@ -31,17 +31,17 @@ import {
WithdrawalSessionRecord,
initRetryInfo,
updateRetryInfoTimeout,
+ ReserveUpdatedEventRecord,
} from "../types/dbTypes";
import {
- Database,
TransactionAbort,
} from "../util/query";
import { Logger } from "../util/logging";
import * as Amounts from "../util/amounts";
import { updateExchangeFromUrl, getExchangeTrust } from "./exchanges";
-import { WithdrawOperationStatusResponse, ReserveStatus } from "../types/talerTypes";
+import { WithdrawOperationStatusResponse } from "../types/talerTypes";
import { assertUnreachable } from "../util/assertUnreachable";
-import { encodeCrock } from "../crypto/talerCrypto";
+import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto";
import { randomBytes } from "../crypto/primitives/nacl-fast";
import {
getVerifiedWithdrawDenomList,
@@ -49,6 +49,7 @@ import {
} from "./withdraw";
import { guardOperationException, OperationFailedAndReportedError } from "./errors";
import { NotificationType } from "../types/notifications";
+import { codecForReserveStatus } from "../types/ReserveStatus";
const logger = new Logger("reserves.ts");
@@ -94,6 +95,7 @@ export async function createReserve(
lastSuccessfulStatusQuery: undefined,
retryInfo: initRetryInfo(),
lastError: undefined,
+ reserveTransactions: [],
};
const senderWire = req.senderWire;
@@ -393,17 +395,35 @@ async function updateReserve(
});
throw new OperationFailedAndReportedError(m);
}
- const reserveInfo = ReserveStatus.checked(await resp.json());
+ const respJson = await resp.json();
+ const reserveInfo = codecForReserveStatus.decode(respJson);
const balance = Amounts.parseOrThrow(reserveInfo.balance);
- await ws.db.mutate(Stores.reserves, reserve.reservePub, r => {
+ await ws.db.runWithWriteTransaction([Stores.reserves, Stores.reserveUpdatedEvents], async (tx) => {
+ const r = await tx.get(Stores.reserves, reservePub);
+ if (!r) {
+ return;
+ }
if (r.reserveStatus !== ReserveRecordStatus.QUERYING_STATUS) {
return;
}
+ const newHistoryTransactions = reserveInfo.history.slice(r.reserveTransactions.length);
+
+ const reserveUpdateId = encodeCrock(getRandomBytes(32));
+
// FIXME: check / compare history!
if (!r.lastSuccessfulStatusQuery) {
// FIXME: check if this matches initial expectations
r.withdrawRemainingAmount = balance;
+ const reserveUpdate: ReserveUpdatedEventRecord = {
+ reservePub: r.reservePub,
+ timestamp: getTimestampNow(),
+ amountReserveBalance: Amounts.toString(balance),
+ amountExpected: Amounts.toString(reserve.initiallyRequestedAmount),
+ newHistoryTransactions,
+ reserveUpdateId,
+ };
+ await tx.put(Stores.reserveUpdatedEvents, reserveUpdate);
} else {
const expectedBalance = Amounts.sub(
r.withdrawAllocatedAmount,
@@ -423,11 +443,21 @@ async function updateReserve(
} else {
// We're missing some money.
}
+ const reserveUpdate: ReserveUpdatedEventRecord = {
+ reservePub: r.reservePub,
+ timestamp: getTimestampNow(),
+ amountReserveBalance: Amounts.toString(balance),
+ amountExpected: Amounts.toString(expectedBalance.amount),
+ newHistoryTransactions,
+ reserveUpdateId,
+ };
+ await tx.put(Stores.reserveUpdatedEvents, reserveUpdate);
}
r.lastSuccessfulStatusQuery = getTimestampNow();
r.reserveStatus = ReserveRecordStatus.WITHDRAWING;
r.retryInfo = initRetryInfo();
- return r;
+ r.reserveTransactions = reserveInfo.history;
+ await tx.put(Stores.reserves, r);
});
ws.notify( { type: NotificationType.ReserveUpdated });
}
@@ -561,7 +591,7 @@ async function depleteReserve(
planchets: denomsForWithdraw.map(x => undefined),
totalCoinValue,
retryInfo: initRetryInfo(),
- lastCoinErrors: denomsForWithdraw.map(x => undefined),
+ lastErrorPerCoin: {},
lastError: undefined,
};
diff --git a/src/operations/tip.ts b/src/operations/tip.ts
index f9953b513..ba4b80974 100644
--- a/src/operations/tip.ts
+++ b/src/operations/tip.ts
@@ -68,7 +68,8 @@ export async function getTipStatus(
tipRecord = {
tipId,
- accepted: false,
+ acceptedTimestamp: undefined,
+ rejectedTimestamp: undefined,
amount,
deadline: extractTalerStampOrThrow(tipPickupStatus.stamp_expire),
exchangeUrl: tipPickupStatus.exchange_url,
@@ -90,7 +91,7 @@ export async function getTipStatus(
}
const tipStatus: TipStatus = {
- accepted: !!tipRecord && tipRecord.accepted,
+ accepted: !!tipRecord && !!tipRecord.acceptedTimestamp,
amount: Amounts.parseOrThrow(tipPickupStatus.amount),
amountLeft: Amounts.parseOrThrow(tipPickupStatus.amount_left),
exchangeUrl: tipPickupStatus.exchange_url,
@@ -259,7 +260,7 @@ async function processTipImpl(
rawWithdrawalAmount: tipRecord.amount,
withdrawn: planchets.map((x) => false),
totalCoinValue: Amounts.sum(planchets.map((p) => p.coinValue)).amount,
- lastCoinErrors: planchets.map((x) => undefined),
+ lastErrorPerCoin: {},
retryInfo: initRetryInfo(),
finishTimestamp: undefined,
lastError: undefined,
@@ -296,7 +297,7 @@ export async function acceptTip(
return;
}
- tipRecord.accepted = true;
+ tipRecord.acceptedTimestamp = getTimestampNow();
await ws.db.put(Stores.tips, tipRecord);
await processTip(ws, tipId);
diff --git a/src/operations/withdraw.ts b/src/operations/withdraw.ts
index a34eec5a1..c7c91494c 100644
--- a/src/operations/withdraw.ts
+++ b/src/operations/withdraw.ts
@@ -272,7 +272,7 @@ async function processPlanchet(
return false;
}
ws.withdrawn[coinIdx] = true;
- ws.lastCoinErrors[coinIdx] = undefined;
+ delete ws.lastErrorPerCoin[coinIdx];
let numDone = 0;
for (let i = 0; i < ws.withdrawn.length; i++) {
if (ws.withdrawn[i]) {
diff --git a/src/types/dbTypes.ts b/src/types/dbTypes.ts
index 7447fc546..897c35038 100644
--- a/src/types/dbTypes.ts
+++ b/src/types/dbTypes.ts
@@ -43,6 +43,7 @@ import {
getTimestampNow,
RefreshReason,
} from "./walletTypes";
+import { ReserveTransaction } from "./ReserveTransaction";
export enum ReserveRecordStatus {
/**
@@ -130,6 +131,7 @@ export function initRetryInfo(
return info;
}
+
/**
* A reserve record as stored in the wallet's database.
*/
@@ -237,6 +239,8 @@ export interface ReserveRecord {
* (either talking to the bank or the exchange).
*/
lastError: OperationError | undefined;
+
+ reserveTransactions: ReserveTransaction[];
}
/**
@@ -449,10 +453,11 @@ export interface ExchangeDetails {
}
export const enum ExchangeUpdateStatus {
- FETCH_KEYS = "fetch_keys",
- FETCH_WIRE = "fetch_wire",
- FETCH_TERMS = "fetch_terms",
- FINISHED = "finished",
+ FetchKeys = "fetch-keys",
+ FetchWire = "fetch-wire",
+ FetchTerms = "fetch-terms",
+ FinalizeUpdate = "finalize-update",
+ Finished = "finished",
}
export interface ExchangeBankAccount {
@@ -464,6 +469,12 @@ export interface ExchangeWireInfo {
accounts: ExchangeBankAccount[];
}
+export const enum ExchangeUpdateReason {
+ Initial = "initial",
+ Forced = "forced",
+ Scheduled = "scheduled",
+}
+
/**
* Exchange record as stored in the wallet's database.
*/
@@ -474,6 +485,11 @@ export interface ExchangeRecord {
baseUrl: string;
/**
+ * Was the exchange added as a built-in exchange?
+ */
+ builtIn: boolean;
+
+ /**
* Details, once known.
*/
details: ExchangeDetails | undefined;
@@ -514,7 +530,7 @@ export interface ExchangeRecord {
*/
updateStarted: Timestamp | undefined;
updateStatus: ExchangeUpdateStatus;
- updateReason?: "initial" | "forced";
+ updateReason?: ExchangeUpdateReason;
lastError?: OperationError;
}
@@ -660,7 +676,7 @@ export interface CoinRecord {
status: CoinStatus;
}
-export enum ProposalStatus {
+export const enum ProposalStatus {
/**
* Not downloaded yet.
*/
@@ -777,11 +793,17 @@ export class ProposalRecord {
*/
export interface TipRecord {
lastError: OperationError | undefined;
+
/**
* Has the user accepted the tip? Only after the tip has been accepted coins
* withdrawn from the tip may be used.
*/
- accepted: boolean;
+ acceptedTimestamp: Timestamp | undefined;
+
+ /**
+ * Has the user rejected the tip?
+ */
+ rejectedTimestamp: Timestamp | undefined;
/**
* Have we picked up the tip record from the merchant already?
@@ -855,7 +877,7 @@ export interface RefreshGroupRecord {
lastError: OperationError | undefined;
- lastErrorPerCoin: (OperationError | undefined)[];
+ lastErrorPerCoin: { [coinIndex: number]: OperationError };
refreshGroupId: string;
@@ -1066,9 +1088,24 @@ export interface PurchaseRefundState {
export interface PayEventRecord {
proposalId: string;
sessionId: string | undefined;
+ isReplay: boolean;
timestamp: Timestamp;
}
+export interface ExchangeUpdatedEventRecord {
+ exchangeBaseUrl: string;
+ timestamp: Timestamp;
+}
+
+export interface ReserveUpdatedEventRecord {
+ amountReserveBalance: string;
+ amountExpected: string;
+ reservePub: string;
+ timestamp: Timestamp;
+ reserveUpdateId: string;
+ newHistoryTransactions: ReserveTransaction[];
+}
+
/**
* Record that stores status information about one purchase, starting from when
* the customer accepts a proposal. Includes refund status if applicable.
@@ -1298,7 +1335,7 @@ export interface WithdrawalSessionRecord {
* Last error per coin/planchet, or undefined if no error occured for
* the coin/planchet.
*/
- lastCoinErrors: (OperationError | undefined)[];
+ lastErrorPerCoin: { [coinIndex: number]: OperationError };
lastError: OperationError | undefined;
}
@@ -1448,6 +1485,18 @@ export namespace Stores {
}
}
+ class ExchangeUpdatedEventsStore extends Store<ExchangeUpdatedEventRecord> {
+ constructor() {
+ super("exchangeUpdatedEvents", { keyPath: "exchangeBaseUrl" });
+ }
+ }
+
+ class ReserveUpdatedEventsStore extends Store<ReserveUpdatedEventRecord> {
+ constructor() {
+ super("reserveUpdatedEvents", { keyPath: "reservePub" });
+ }
+ }
+
class BankWithdrawUrisStore extends Store<BankWithdrawUriRecord> {
constructor() {
super("bankWithdrawUris", { keyPath: "talerWithdrawUri" });
@@ -1474,6 +1523,8 @@ export namespace Stores {
export const bankWithdrawUris = new BankWithdrawUrisStore();
export const refundEvents = new RefundEventsStore();
export const payEvents = new PayEventsStore();
+ export const reserveUpdatedEvents = new ReserveUpdatedEventsStore();
+ export const exchangeUpdatedEvents = new ExchangeUpdatedEventsStore();
}
/* tslint:enable:completed-docs */
diff --git a/src/types/history.ts b/src/types/history.ts
index 54004b122..210006312 100644
--- a/src/types/history.ts
+++ b/src/types/history.ts
@@ -1,4 +1,5 @@
import { Timestamp, RefreshReason } from "./walletTypes";
+import { ReserveTransaction } from "./ReserveTransaction";
/*
This file is part of GNU Taler
@@ -140,10 +141,7 @@ export interface HistoryReserveBalanceUpdatedEvent {
*/
timestamp: Timestamp;
- /**
- * Unique identifier to query more information about this update.
- */
- reserveUpdateId: string;
+ newHistoryTransactions: ReserveTransaction[];
/**
* Condensed information about the reserve.
@@ -210,13 +208,7 @@ export interface HistoryTipAcceptedEvent {
/**
* Raw amount of the tip, without extra fees that apply.
*/
- tipRawAmount: string;
-
- /**
- * Amount that the user effectively adds to their balance when
- * the tip is accepted.
- */
- tipEffectiveAmount: string;
+ tipRaw: string;
}
/**
@@ -238,13 +230,7 @@ export interface HistoryTipDeclinedEvent {
/**
* Raw amount of the tip, without extra fees that apply.
*/
- tipRawAmount: string;
-
- /**
- * Amount that the user effectively adds to their balance when
- * the tip is accepted.
- */
- tipEffectiveAmount: string;
+ tipAmount: string;
}
/**
@@ -454,14 +440,7 @@ export interface OrderShortInfo {
/**
* Amount that must be paid for the contract.
*/
- amountRequested: string;
-
- /**
- * Amount that would be subtracted from the wallet when paying,
- * includes fees and funds lost due to refreshing or left-over
- * amounts too small to refresh.
- */
- amountEffective: string;
+ amount: string;
/**
* Summary of the proposal, given by the merchant.
@@ -548,7 +527,7 @@ export interface HistoryPaymentSent {
/**
* Type tag.
*/
- type: HistoryEventType.PaymentAborted;
+ type: HistoryEventType.PaymentSent;
/**
* Condensed info about the order that we already paid for.
@@ -584,7 +563,7 @@ export interface HistoryRefund {
* Unique identifier for this refund.
* (Identifies multiple refund permissions that were obtained at once.)
*/
- refundId: string;
+ refundGroupId: string;
/**
* Part of the refund that couldn't be applied because
@@ -616,13 +595,22 @@ export interface HistoryRefreshedEvent {
* Amount that is now available again because it has
* been refreshed.
*/
- amountRefreshed: string;
+ amountRefreshedEffective: string;
+
+ /**
+ * Amount that we spent for refreshing.
+ */
+ amountRefreshedRaw: string;
/**
* Why was the refreshing done?
*/
refreshReason: RefreshReason;
+ numInputCoins: number;
+ numRefreshedInputCoins: number;
+ numOutputCoins: number;
+
/**
* Identifier for a refresh group, contains one or
* more refresh session IDs.
diff --git a/src/types/pending.ts b/src/types/pending.ts
index d08d2c54e..53932e8f3 100644
--- a/src/types/pending.ts
+++ b/src/types/pending.ts
@@ -32,6 +32,7 @@ export const enum PendingOperationType {
ProposalDownload = "proposal-download",
Refresh = "refresh",
Reserve = "reserve",
+ Recoup = "recoup",
RefundApply = "refund-apply",
RefundQuery = "refund-query",
TipChoice = "tip-choice",
@@ -53,6 +54,7 @@ export type PendingOperationInfo = PendingOperationInfoCommon &
| PendingRefundApplyOperation
| PendingRefundQueryOperation
| PendingReserveOperation
+ | PendingTipChoiceOperation
| PendingTipPickupOperation
| PendingWithdrawOperation
);
@@ -115,6 +117,13 @@ export interface PendingTipPickupOperation {
merchantTipId: string;
}
+export interface PendingTipChoiceOperation {
+ type: PendingOperationType.TipChoice;
+ tipId: string;
+ merchantBaseUrl: string;
+ merchantTipId: string;
+}
+
export interface PendingPayOperation {
type: PendingOperationType.Pay;
proposalId: string;
@@ -147,8 +156,18 @@ export interface PendingWithdrawOperation {
numCoinsTotal: number;
}
+export interface PendingOperationFlags {
+ isWaitingUser: boolean;
+ isError: boolean;
+ givesLifeness: boolean;
+}
+
export interface PendingOperationInfoCommon {
+ /**
+ * Type of the pending operation.
+ */
type: PendingOperationType;
+
givesLifeness: boolean;
}
diff --git a/src/types/talerTypes.ts b/src/types/talerTypes.ts
index df89b9979..bb286b648 100644
--- a/src/types/talerTypes.ts
+++ b/src/types/talerTypes.ts
@@ -639,28 +639,6 @@ export class ReserveSigSingleton {
static checked: (obj: any) => ReserveSigSingleton;
}
-/**
- * Response to /reserve/status
- */
-@Checkable.Class()
-export class ReserveStatus {
- /**
- * Reserve signature.
- */
- @Checkable.String()
- balance: string;
-
- /**
- * Reserve history, currently not used by the wallet.
- */
- @Checkable.Any()
- history: any;
-
- /**
- * Create a ReserveSigSingleton from untyped JSON.
- */
- static checked: (obj: any) => ReserveStatus;
-}
/**
* Response of the merchant
@@ -942,3 +920,11 @@ export class TipPickupGetResponse {
*/
static checked: (obj: any) => TipPickupGetResponse;
}
+
+
+export type AmountString = string;
+export type Base32String = string;
+export type EddsaSignatureString = string;
+export type EddsaPublicKeyString = string;
+export type CoinPublicKeyString = string;
+export type TimestampString = string; \ No newline at end of file
diff --git a/src/util/amounts.ts b/src/util/amounts.ts
index 26cee7f8f..c8fb76793 100644
--- a/src/util/amounts.ts
+++ b/src/util/amounts.ts
@@ -22,7 +22,6 @@
* Imports.
*/
import { Checkable } from "./checkable";
-import { objectCodec, numberCodec, stringCodec, Codec } from "./codec";
/**
* Number of fractional units that one value unit represents.
@@ -68,12 +67,6 @@ export class AmountJson {
static checked: (obj: any) => AmountJson;
}
-const amountJsonCodec: Codec<AmountJson> = objectCodec<AmountJson>()
- .property("value", numberCodec)
- .property("fraction", numberCodec)
- .property("currency", stringCodec)
- .build("AmountJson");
-
/**
* Result of a possibly overflowing operation.
*/
diff --git a/src/util/codec-test.ts b/src/util/codec-test.ts
index 22f6a0a98..7c7c93c7b 100644
--- a/src/util/codec-test.ts
+++ b/src/util/codec-test.ts
@@ -19,13 +19,7 @@
*/
import test from "ava";
-import {
- stringCodec,
- objectCodec,
- unionCodec,
- Codec,
- stringConstCodec,
-} from "./codec";
+import { Codec, makeCodecForObject, makeCodecForConstString, codecForString, makeCodecForUnion } from "./codec";
interface MyObj {
foo: string;
@@ -44,8 +38,8 @@ interface AltTwo {
type MyUnion = AltOne | AltTwo;
test("basic codec", t => {
- const myObjCodec = objectCodec<MyObj>()
- .property("foo", stringCodec)
+ const myObjCodec = makeCodecForObject<MyObj>()
+ .property("foo", codecForString)
.build("MyObj");
const res = myObjCodec.decode({ foo: "hello" });
t.assert(res.foo === "hello");
@@ -56,15 +50,15 @@ test("basic codec", t => {
});
test("union", t => {
- const altOneCodec: Codec<AltOne> = objectCodec<AltOne>()
- .property("type", stringConstCodec("one"))
- .property("foo", stringCodec)
+ const altOneCodec: Codec<AltOne> = makeCodecForObject<AltOne>()
+ .property("type", makeCodecForConstString("one"))
+ .property("foo", codecForString)
.build("AltOne");
- const altTwoCodec: Codec<AltTwo> = objectCodec<AltTwo>()
- .property("type", stringConstCodec("two"))
- .property("bar", stringCodec)
+ const altTwoCodec: Codec<AltTwo> = makeCodecForObject<AltTwo>()
+ .property("type", makeCodecForConstString("two"))
+ .property("bar", codecForString)
.build("AltTwo");
- const myUnionCodec: Codec<MyUnion> = unionCodec<MyUnion>()
+ const myUnionCodec: Codec<MyUnion> = makeCodecForUnion<MyUnion>()
.discriminateOn("type")
.alternative("one", altOneCodec)
.alternative("two", altTwoCodec)
diff --git a/src/util/codec.ts b/src/util/codec.ts
index 0215ce797..a13816c59 100644
--- a/src/util/codec.ts
+++ b/src/util/codec.ts
@@ -74,16 +74,16 @@ interface Alternative {
codec: Codec<any>;
}
-class ObjectCodecBuilder<T, TC> {
+class ObjectCodecBuilder<OutputType, PartialOutputType> {
private propList: Prop[] = [];
/**
* Define a property for the object.
*/
- property<K extends keyof T & string, V extends T[K]>(
+ property<K extends keyof OutputType & string, V extends OutputType[K]>(
x: K,
codec: Codec<V>,
- ): ObjectCodecBuilder<T, TC & SingletonRecord<K, V>> {
+ ): ObjectCodecBuilder<OutputType, PartialOutputType & SingletonRecord<K, V>> {
this.propList.push({ name: x, codec: codec });
return this as any;
}
@@ -94,10 +94,10 @@ class ObjectCodecBuilder<T, TC> {
* @param objectDisplayName name of the object that this codec operates on,
* used in error messages.
*/
- build(objectDisplayName: string): Codec<TC> {
+ build(objectDisplayName: string): Codec<PartialOutputType> {
const propList = this.propList;
return {
- decode(x: any, c?: Context): TC {
+ decode(x: any, c?: Context): PartialOutputType {
if (!c) {
c = {
path: [`(${objectDisplayName})`],
@@ -112,24 +112,37 @@ class ObjectCodecBuilder<T, TC> {
);
obj[prop.name] = propVal;
}
- return obj as TC;
+ return obj as PartialOutputType;
},
};
}
}
-class UnionCodecBuilder<T, D extends keyof T, B, TC> {
+class UnionCodecBuilder<
+ TargetType,
+ TagPropertyLabel extends keyof TargetType,
+ CommonBaseType,
+ PartialTargetType
+> {
private alternatives = new Map<any, Alternative>();
- constructor(private discriminator: D, private baseCodec?: Codec<B>) {}
+ constructor(
+ private discriminator: TagPropertyLabel,
+ private baseCodec?: Codec<CommonBaseType>,
+ ) {}
/**
* Define a property for the object.
*/
alternative<V>(
- tagValue: T[D],
+ tagValue: TargetType[TagPropertyLabel],
codec: Codec<V>,
- ): UnionCodecBuilder<T, D, B, TC | V> {
+ ): UnionCodecBuilder<
+ TargetType,
+ TagPropertyLabel,
+ CommonBaseType,
+ PartialTargetType | V
+ > {
this.alternatives.set(tagValue, { codec, tagValue });
return this as any;
}
@@ -140,7 +153,9 @@ class UnionCodecBuilder<T, D extends keyof T, B, TC> {
* @param objectDisplayName name of the object that this codec operates on,
* used in error messages.
*/
- build<R extends TC & B>(objectDisplayName: string): Codec<R> {
+ build<R extends PartialTargetType & CommonBaseType = never>(
+ objectDisplayName: string,
+ ): Codec<R> {
const alternatives = this.alternatives;
const discriminator = this.discriminator;
const baseCodec = this.baseCodec;
@@ -174,50 +189,50 @@ class UnionCodecBuilder<T, D extends keyof T, B, TC> {
}
}
+export class UnionCodecPreBuilder<T> {
+ discriminateOn<D extends keyof T, B = {}>(
+ discriminator: D,
+ baseCodec?: Codec<B>,
+ ): UnionCodecBuilder<T, D, B, never> {
+ return new UnionCodecBuilder<T, D, B, never>(discriminator, baseCodec);
+ }
+}
+
/**
- * Return a codec for a value that must be a string.
+ * Return a builder for a codec that decodes an object with properties.
*/
-export const stringCodec: Codec<string> = {
- decode(x: any, c?: Context): string {
- if (typeof x === "string") {
- return x;
- }
- throw new DecodingError(`expected string at ${renderContext(c)}`);
- },
-};
+export function makeCodecForObject<T>(): ObjectCodecBuilder<T, {}> {
+ return new ObjectCodecBuilder<T, {}>();
+}
+
+export function makeCodecForUnion<T>(): UnionCodecPreBuilder<T> {
+ return new UnionCodecPreBuilder<T>();
+}
/**
- * Return a codec for a value that must be a string.
+ * Return a codec for a mapping from a string to values described by the inner codec.
*/
-export function stringConstCodec<V extends string>(s: V): Codec<V> {
+export function makeCodecForMap<T>(
+ innerCodec: Codec<T>,
+): Codec<{ [x: string]: T }> {
return {
- decode(x: any, c?: Context): V {
- if (x === s) {
- return x;
+ decode(x: any, c?: Context): { [x: string]: T } {
+ const map: { [x: string]: T } = {};
+ if (typeof x !== "object") {
+ throw new DecodingError(`expected object at ${renderContext(c)}`);
}
- throw new DecodingError(
- `expected string constant "${s}" at ${renderContext(c)}`,
- );
+ for (const i in x) {
+ map[i] = innerCodec.decode(x[i], joinContext(c, `[${i}]`));
+ }
+ return map;
},
};
}
/**
- * Return a codec for a value that must be a number.
- */
-export const numberCodec: Codec<number> = {
- decode(x: any, c?: Context): number {
- if (typeof x === "number") {
- return x;
- }
- throw new DecodingError(`expected number at ${renderContext(c)}`);
- },
-};
-
-/**
* Return a codec for a list, containing values described by the inner codec.
*/
-export function listCodec<T>(innerCodec: Codec<T>): Codec<T[]> {
+export function makeCodecForList<T>(innerCodec: Codec<T>): Codec<T[]> {
return {
decode(x: any, c?: Context): T[] {
const arr: T[] = [];
@@ -233,39 +248,45 @@ export function listCodec<T>(innerCodec: Codec<T>): Codec<T[]> {
}
/**
- * Return a codec for a mapping from a string to values described by the inner codec.
+ * Return a codec for a value that must be a number.
*/
-export function mapCodec<T>(innerCodec: Codec<T>): Codec<{ [x: string]: T }> {
- return {
- decode(x: any, c?: Context): { [x: string]: T } {
- const map: { [x: string]: T } = {};
- if (typeof x !== "object") {
- throw new DecodingError(`expected object at ${renderContext(c)}`);
- }
- for (const i in x) {
- map[i] = innerCodec.decode(x[i], joinContext(c, `[${i}]`));
- }
- return map;
- },
- };
-}
+export const codecForNumber: Codec<number> = {
+ decode(x: any, c?: Context): number {
+ if (typeof x === "number") {
+ return x;
+ }
+ throw new DecodingError(`expected number at ${renderContext(c)}`);
+ },
+};
-export class UnionCodecPreBuilder<T> {
- discriminateOn<D extends keyof T, B>(
- discriminator: D,
- baseCodec?: Codec<B>,
- ): UnionCodecBuilder<T, D, B, never> {
- return new UnionCodecBuilder<T, D, B, never>(discriminator, baseCodec);
- }
-}
+/**
+ * Return a codec for a value that must be a string.
+ */
+export const codecForString: Codec<string> = {
+ decode(x: any, c?: Context): string {
+ if (typeof x === "string") {
+ return x;
+ }
+ throw new DecodingError(`expected string at ${renderContext(c)}`);
+ },
+};
/**
- * Return a builder for a codec that decodes an object with properties.
+ * Return a codec for a value that must be a string.
*/
-export function objectCodec<T>(): ObjectCodecBuilder<T, {}> {
- return new ObjectCodecBuilder<T, {}>();
+export function makeCodecForConstString<V extends string>(s: V): Codec<V> {
+ return {
+ decode(x: any, c?: Context): V {
+ if (x === s) {
+ return x;
+ }
+ throw new DecodingError(
+ `expected string constant "${s}" at ${renderContext(c)}`,
+ );
+ },
+ };
}
-export function unionCodec<T>(): UnionCodecPreBuilder<T> {
- return new UnionCodecPreBuilder<T>();
+export function typecheckedCodec<T = undefined>(c: Codec<T>): Codec<T> {
+ return c;
}
diff --git a/src/util/helpers.ts b/src/util/helpers.ts
index 99d046f04..8136f44fa 100644
--- a/src/util/helpers.ts
+++ b/src/util/helpers.ts
@@ -214,3 +214,13 @@ export function strcmp(s1: string, s2: string): number {
}
return 0;
}
+
+/**
+ * Run a function and return its result.
+ *
+ * Used as a nicer-looking way to do immediately invoked function
+ * expressions (IFFEs).
+ */
+export function runBlock<T>(f: () => T) {
+ return f();
+} \ No newline at end of file
diff --git a/src/util/query.ts b/src/util/query.ts
index 08a8fec02..217c0674e 100644
--- a/src/util/query.ts
+++ b/src/util/query.ts
@@ -176,6 +176,17 @@ class ResultStream<T> {
return arr;
}
+ async forEachAsync(f: (x: T) => Promise<void>): Promise<void> {
+ while (true) {
+ const x = await this.next();
+ if (x.hasValue) {
+ await f(x.value);
+ } else {
+ break;
+ }
+ }
+ }
+
async forEach(f: (x: T) => void): Promise<void> {
while (true) {
const x = await this.next();
diff --git a/src/wallet.ts b/src/wallet.ts
index aca8a18ac..3d28d089f 100644
--- a/src/wallet.ts
+++ b/src/wallet.ts
@@ -24,9 +24,7 @@
*/
import { CryptoWorkerFactory } from "./crypto/workers/cryptoApi";
import { HttpRequestLibrary } from "./util/http";
-import {
- Database
-} from "./util/query";
+import { Database } from "./util/query";
import { AmountJson } from "./util/amounts";
import * as Amounts from "./util/amounts";
@@ -99,10 +97,19 @@ import { payback } from "./operations/payback";
import { TimerGroup } from "./util/timer";
import { AsyncCondition } from "./util/promiseUtils";
import { AsyncOpMemoSingle } from "./util/asyncMemo";
-import { PendingOperationInfo, PendingOperationsResponse, PendingOperationType } from "./types/pending";
+import {
+ PendingOperationInfo,
+ PendingOperationsResponse,
+ PendingOperationType,
+} from "./types/pending";
import { WalletNotification, NotificationType } from "./types/notifications";
import { HistoryQuery, HistoryEvent } from "./types/history";
-import { processPurchaseQueryRefund, processPurchaseApplyRefund, getFullRefundFees, applyRefund } from "./operations/refund";
+import {
+ processPurchaseQueryRefund,
+ processPurchaseApplyRefund,
+ getFullRefundFees,
+ applyRefund,
+} from "./operations/refund";
/**
* Wallet protocol version spoken with the exchange
@@ -184,11 +191,7 @@ export class Wallet {
await updateExchangeFromUrl(this.ws, pending.exchangeBaseUrl, forceNow);
break;
case PendingOperationType.Refresh:
- await processRefreshGroup(
- this.ws,
- pending.refreshGroupId,
- forceNow,
- );
+ await processRefreshGroup(this.ws, pending.refreshGroupId, forceNow);
break;
case PendingOperationType.Reserve:
await processReserve(this.ws, pending.reservePub, forceNow);
@@ -203,9 +206,12 @@ export class Wallet {
case PendingOperationType.ProposalChoice:
// Nothing to do, user needs to accept/reject
break;
- case PendingOperationType.ProposalDownload:
+ case PendingOperationType.ProposalDownload:
await processDownloadProposal(this.ws, pending.proposalId, forceNow);
break;
+ case PendingOperationType.TipChoice:
+ // Nothing to do, user needs to accept/reject
+ break;
case PendingOperationType.TipPickup:
await processTip(this.ws, pending.tipId, forceNow);
break;
@@ -470,9 +476,16 @@ export class Wallet {
async refresh(oldCoinPub: string): Promise<void> {
try {
- const refreshGroupId = await this.db.runWithWriteTransaction([Stores.refreshGroups], async (tx) => {
- return await createRefreshGroup(tx, [{ coinPub: oldCoinPub }], RefreshReason.Manual);
- });
+ const refreshGroupId = await this.db.runWithWriteTransaction(
+ [Stores.refreshGroups],
+ async tx => {
+ return await createRefreshGroup(
+ tx,
+ [{ coinPub: oldCoinPub }],
+ RefreshReason.Manual,
+ );
+ },
+ );
await processRefreshGroup(this.ws, refreshGroupId.refreshGroupId);
} catch (e) {
this.latch.trigger();
@@ -510,10 +523,9 @@ export class Wallet {
}
async getDenoms(exchangeUrl: string): Promise<DenominationRecord[]> {
- const denoms = await this.db.iterIndex(
- Stores.denominations.exchangeBaseUrlIndex,
- exchangeUrl,
- ).toArray();
+ const denoms = await this.db
+ .iterIndex(Stores.denominations.exchangeBaseUrlIndex, exchangeUrl)
+ .toArray();
return denoms;
}
@@ -536,15 +548,15 @@ export class Wallet {
}
async getReserves(exchangeBaseUrl: string): Promise<ReserveRecord[]> {
- return await this.db.iter(Stores.reserves).filter(
- r => r.exchangeBaseUrl === exchangeBaseUrl,
- );
+ return await this.db
+ .iter(Stores.reserves)
+ .filter(r => r.exchangeBaseUrl === exchangeBaseUrl);
}
async getCoinsForExchange(exchangeBaseUrl: string): Promise<CoinRecord[]> {
- return await this.db.iter(Stores.coins).filter(
- c => c.exchangeBaseUrl === exchangeBaseUrl,
- );
+ return await this.db
+ .iter(Stores.coins)
+ .filter(c => c.exchangeBaseUrl === exchangeBaseUrl);
}
async getCoins(): Promise<CoinRecord[]> {
@@ -556,9 +568,7 @@ export class Wallet {
}
async getPaybackReserves(): Promise<ReserveRecord[]> {
- return await this.db.iter(Stores.reserves).filter(
- r => r.hasPayback,
- );
+ return await this.db.iter(Stores.reserves).filter(r => r.hasPayback);
}
/**
@@ -691,9 +701,9 @@ export class Wallet {
if (!purchase) {
throw Error("unknown purchase");
}
- const refundsDoneAmounts = Object.values(purchase.refundState.refundsDone).map(x =>
- Amounts.parseOrThrow(x.perm.refund_amount),
- );
+ const refundsDoneAmounts = Object.values(
+ purchase.refundState.refundsDone,
+ ).map(x => Amounts.parseOrThrow(x.perm.refund_amount));
const refundsPendingAmounts = Object.values(
purchase.refundState.refundsPending,
).map(x => Amounts.parseOrThrow(x.perm.refund_amount));
@@ -701,12 +711,12 @@ export class Wallet {
...refundsDoneAmounts,
...refundsPendingAmounts,
]).amount;
- const refundsDoneFees = Object.values(purchase.refundState.refundsDone).map(x =>
- Amounts.parseOrThrow(x.perm.refund_amount),
- );
- const refundsPendingFees = Object.values(purchase.refundState.refundsPending).map(x =>
- Amounts.parseOrThrow(x.perm.refund_amount),
- );
+ const refundsDoneFees = Object.values(
+ purchase.refundState.refundsDone,
+ ).map(x => Amounts.parseOrThrow(x.perm.refund_amount));
+ const refundsPendingFees = Object.values(
+ purchase.refundState.refundsPending,
+ ).map(x => Amounts.parseOrThrow(x.perm.refund_amount));
const totalRefundFees = Amounts.sum([
...refundsDoneFees,
...refundsPendingFees,
diff --git a/tsconfig.json b/tsconfig.json
index 81e529fad..ab2c42e1a 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -60,6 +60,8 @@
"src/operations/state.ts",
"src/operations/tip.ts",
"src/operations/withdraw.ts",
+ "src/types/ReserveStatus.ts",
+ "src/types/ReserveTransaction.ts",
"src/types/dbTypes.ts",
"src/types/history.ts",
"src/types/notifications.ts",