From fa4621e70c48500a372504eb8ae9b9481531c555 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Mon, 16 Dec 2019 12:53:22 +0100 Subject: history events WIP --- src/operations/exchanges.ts | 75 +++++++--- src/operations/history.ts | 337 +++++++++++++++++++++++++++++++++++++++++++- src/operations/pay.ts | 1 + src/operations/pending.ts | 18 ++- src/operations/refresh.ts | 2 +- src/operations/refund.ts | 120 ++++++++++------ src/operations/reserves.ts | 44 +++++- src/operations/tip.ts | 9 +- src/operations/withdraw.ts | 2 +- src/types/dbTypes.ts | 69 +++++++-- src/types/history.ts | 46 +++--- src/types/pending.ts | 19 +++ src/types/talerTypes.ts | 30 ++-- src/util/amounts.ts | 7 - src/util/codec-test.ts | 26 ++-- src/util/codec.ts | 159 ++++++++++++--------- src/util/helpers.ts | 10 ++ src/util/query.ts | 11 ++ src/wallet.ts | 82 ++++++----- tsconfig.json | 2 + 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 { - 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. */ @@ -473,6 +484,11 @@ export interface ExchangeRecord { */ baseUrl: string; + /** + * Was the exchange added as a built-in exchange? + */ + builtIn: boolean; + /** * Details, once known. */ @@ -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 { + constructor() { + super("exchangeUpdatedEvents", { keyPath: "exchangeBaseUrl" }); + } + } + + class ReserveUpdatedEventsStore extends Store { + constructor() { + super("reserveUpdatedEvents", { keyPath: "reservePub" }); + } + } + class BankWithdrawUrisStore extends Store { 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 = objectCodec() - .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() - .property("foo", stringCodec) + const myObjCodec = makeCodecForObject() + .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 = objectCodec() - .property("type", stringConstCodec("one")) - .property("foo", stringCodec) + const altOneCodec: Codec = makeCodecForObject() + .property("type", makeCodecForConstString("one")) + .property("foo", codecForString) .build("AltOne"); - const altTwoCodec: Codec = objectCodec() - .property("type", stringConstCodec("two")) - .property("bar", stringCodec) + const altTwoCodec: Codec = makeCodecForObject() + .property("type", makeCodecForConstString("two")) + .property("bar", codecForString) .build("AltTwo"); - const myUnionCodec: Codec = unionCodec() + const myUnionCodec: Codec = makeCodecForUnion() .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; } -class ObjectCodecBuilder { +class ObjectCodecBuilder { private propList: Prop[] = []; /** * Define a property for the object. */ - property( + property( x: K, codec: Codec, - ): ObjectCodecBuilder> { + ): ObjectCodecBuilder> { this.propList.push({ name: x, codec: codec }); return this as any; } @@ -94,10 +94,10 @@ class ObjectCodecBuilder { * @param objectDisplayName name of the object that this codec operates on, * used in error messages. */ - build(objectDisplayName: string): Codec { + build(objectDisplayName: string): Codec { 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 { ); obj[prop.name] = propVal; } - return obj as TC; + return obj as PartialOutputType; }, }; } } -class UnionCodecBuilder { +class UnionCodecBuilder< + TargetType, + TagPropertyLabel extends keyof TargetType, + CommonBaseType, + PartialTargetType +> { private alternatives = new Map(); - constructor(private discriminator: D, private baseCodec?: Codec) {} + constructor( + private discriminator: TagPropertyLabel, + private baseCodec?: Codec, + ) {} /** * Define a property for the object. */ alternative( - tagValue: T[D], + tagValue: TargetType[TagPropertyLabel], codec: Codec, - ): UnionCodecBuilder { + ): UnionCodecBuilder< + TargetType, + TagPropertyLabel, + CommonBaseType, + PartialTargetType | V + > { this.alternatives.set(tagValue, { codec, tagValue }); return this as any; } @@ -140,7 +153,9 @@ class UnionCodecBuilder { * @param objectDisplayName name of the object that this codec operates on, * used in error messages. */ - build(objectDisplayName: string): Codec { + build( + objectDisplayName: string, + ): Codec { const alternatives = this.alternatives; const discriminator = this.discriminator; const baseCodec = this.baseCodec; @@ -174,50 +189,50 @@ class UnionCodecBuilder { } } +export class UnionCodecPreBuilder { + discriminateOn( + discriminator: D, + baseCodec?: Codec, + ): UnionCodecBuilder { + return new UnionCodecBuilder(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 = { - decode(x: any, c?: Context): string { - if (typeof x === "string") { - return x; - } - throw new DecodingError(`expected string at ${renderContext(c)}`); - }, -}; +export function makeCodecForObject(): ObjectCodecBuilder { + return new ObjectCodecBuilder(); +} + +export function makeCodecForUnion(): UnionCodecPreBuilder { + return new UnionCodecPreBuilder(); +} /** - * 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(s: V): Codec { +export function makeCodecForMap( + innerCodec: Codec, +): 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 = { - 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(innerCodec: Codec): Codec { +export function makeCodecForList(innerCodec: Codec): Codec { return { decode(x: any, c?: Context): T[] { const arr: T[] = []; @@ -233,39 +248,45 @@ export function listCodec(innerCodec: Codec): Codec { } /** - * 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(innerCodec: Codec): 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 = { + decode(x: any, c?: Context): number { + if (typeof x === "number") { + return x; + } + throw new DecodingError(`expected number at ${renderContext(c)}`); + }, +}; -export class UnionCodecPreBuilder { - discriminateOn( - discriminator: D, - baseCodec?: Codec, - ): UnionCodecBuilder { - return new UnionCodecBuilder(discriminator, baseCodec); - } -} +/** + * Return a codec for a value that must be a string. + */ +export const codecForString: Codec = { + 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(): ObjectCodecBuilder { - return new ObjectCodecBuilder(); +export function makeCodecForConstString(s: V): Codec { + 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(): UnionCodecPreBuilder { - return new UnionCodecPreBuilder(); +export function typecheckedCodec(c: Codec): Codec { + 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(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 { return arr; } + async forEachAsync(f: (x: T) => Promise): Promise { + while (true) { + const x = await this.next(); + if (x.hasValue) { + await f(x.value); + } else { + break; + } + } + } + async forEach(f: (x: T) => void): Promise { 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 { 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 { - 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 { - 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 { - 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 { @@ -556,9 +568,7 @@ export class Wallet { } async getPaybackReserves(): Promise { - 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", -- cgit v1.2.3