diff options
Diffstat (limited to 'src/operations')
-rw-r--r-- | src/operations/exchanges.ts | 75 | ||||
-rw-r--r-- | src/operations/history.ts | 337 | ||||
-rw-r--r-- | src/operations/pay.ts | 1 | ||||
-rw-r--r-- | src/operations/pending.ts | 18 | ||||
-rw-r--r-- | src/operations/refresh.ts | 2 | ||||
-rw-r--r-- | src/operations/refund.ts | 120 | ||||
-rw-r--r-- | src/operations/reserves.ts | 44 | ||||
-rw-r--r-- | src/operations/tip.ts | 9 | ||||
-rw-r--r-- | src/operations/withdraw.ts | 2 |
9 files changed, 520 insertions, 88 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]) { |