From 4159367d8c04b96a05da8a5ded043f8296a83174 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Fri, 6 Dec 2019 02:52:16 +0100 Subject: pending ops / history / notification tweaks --- src/android/index.ts | 2 + src/dbTypes.ts | 5 +- src/headless/helpers.ts | 11 +- src/wallet-impl/balance.ts | 2 +- src/wallet-impl/history.ts | 307 +++++++++++++++++++++++++------------------- src/wallet-impl/pay.ts | 23 +++- src/wallet-impl/pending.ts | 2 +- src/wallet-impl/refresh.ts | 1 + src/wallet-impl/reserves.ts | 5 + src/wallet-impl/tip.ts | 3 +- src/wallet-impl/withdraw.ts | 14 +- src/walletTypes.ts | 81 +++++++++++- 12 files changed, 305 insertions(+), 151 deletions(-) diff --git a/src/android/index.ts b/src/android/index.ts index dcdb9d756..300cffd12 100644 --- a/src/android/index.ts +++ b/src/android/index.ts @@ -31,6 +31,8 @@ import { HttpRequestLibrary, HttpResponse } from "../util/http"; // @ts-ignore: special built-in module //import akono = require("akono"); +export { handleWorkerError, handleWorkerMessage } from "../crypto/workers/nodeThreadWorker"; + export class AndroidHttpLib implements HttpRequestLibrary { useNfcTunnel: boolean = false; diff --git a/src/dbTypes.ts b/src/dbTypes.ts index e39d73672..3625740e2 100644 --- a/src/dbTypes.ts +++ b/src/dbTypes.ts @@ -1003,10 +1003,7 @@ export interface PurchaseRecord { */ merchantSig: string; - /** - * A successful payment has been made. - */ - payFinished: boolean; + firstSuccessfulPayTimestamp: Timestamp | undefined; /** * Pending refunds for the purchase. diff --git a/src/headless/helpers.ts b/src/headless/helpers.ts index cfc7e3695..c4c84d44b 100644 --- a/src/headless/helpers.ts +++ b/src/headless/helpers.ts @@ -32,7 +32,8 @@ import { Bank } from "./bank"; import fs = require("fs"); import { Logger } from "../util/logging"; import { NodeThreadCryptoWorkerFactory } from "../crypto/workers/nodeThreadWorker"; -import { NotificationType } from "../walletTypes"; +import { NotificationType, WalletNotification } from "../walletTypes"; +import { SynchronousCryptoWorkerFactory } from "../crypto/workers/synchronousWorker"; const logger = new Logger("helpers.ts"); @@ -87,7 +88,7 @@ export interface DefaultNodeWalletArgs { /** * Handler for asynchronous notifications from the wallet. */ - notifyHandler?: (reason: string) => void; + notifyHandler?: (n: WalletNotification) => void; /** * If specified, use this as HTTP request library instead @@ -163,11 +164,15 @@ export async function getDefaultNodeWallet( const worker = new NodeThreadCryptoWorkerFactory(); - return new Wallet( + const w = new Wallet( myDb, myHttpLib, worker, ); + if (args.notifyHandler) { + w.addNotificationListener(args.notifyHandler); + } + return w; } export async function withdrawTestBalance( diff --git a/src/wallet-impl/balance.ts b/src/wallet-impl/balance.ts index 082e62563..8ce91a173 100644 --- a/src/wallet-impl/balance.ts +++ b/src/wallet-impl/balance.ts @@ -138,7 +138,7 @@ export async function getBalances( }); await tx.iter(Stores.purchases).forEach(t => { - if (t.payFinished) { + if (t.firstSuccessfulPayTimestamp) { return; } for (const c of t.payReq.coins) { diff --git a/src/wallet-impl/history.ts b/src/wallet-impl/history.ts index 23887e895..99e51c8de 100644 --- a/src/wallet-impl/history.ts +++ b/src/wallet-impl/history.ts @@ -14,11 +14,11 @@ GNU Taler; see the file COPYING. If not, see */ - /** - * Imports. - */ +/** + * Imports. + */ import { HistoryQuery, HistoryEvent } from "../walletTypes"; -import { oneShotIter } from "../util/query"; +import { oneShotIter, runWithReadTransaction } from "../util/query"; import { InternalWalletState } from "./state"; import { Stores, TipRecord } from "../dbTypes"; import * as Amounts from "../util/amounts"; @@ -34,139 +34,186 @@ export async function getHistory( const history: HistoryEvent[] = []; // FIXME: do pagination instead of generating the full history - // We uniquely identify history rows via their timestamp. // This works as timestamps are guaranteed to be monotonically // increasing even - /* - const proposals = await oneShotIter(ws.db, Stores.proposals).toArray(); - for (const p of proposals) { - history.push({ - detail: { - contractTermsHash: p.contractTermsHash, - merchantName: p.contractTerms.merchant.name, - }, - timestamp: p.timestamp, - type: "claim-order", - explicit: false, - }); - } - */ - - const withdrawals = await oneShotIter( + await runWithReadTransaction( ws.db, - Stores.withdrawalSession, - ).toArray(); - for (const w of withdrawals) { - history.push({ - detail: { - withdrawalAmount: w.rawWithdrawalAmount, - }, - timestamp: w.startTimestamp, - type: "withdraw", - explicit: false, - }); - } - - const purchases = await oneShotIter(ws.db, Stores.purchases).toArray(); - for (const p of purchases) { - history.push({ - detail: { - amount: p.contractTerms.amount, - contractTermsHash: p.contractTermsHash, - fulfillmentUrl: p.contractTerms.fulfillment_url, - merchantName: p.contractTerms.merchant.name, - }, - timestamp: p.acceptTimestamp, - type: "pay", - explicit: false, - }); - if (p.lastRefundStatusTimestamp) { - const contractAmount = Amounts.parseOrThrow(p.contractTerms.amount); - const amountsPending = Object.keys(p.refundsPending).map(x => - Amounts.parseOrThrow(p.refundsPending[x].refund_amount), - ); - const amountsDone = Object.keys(p.refundsDone).map(x => - Amounts.parseOrThrow(p.refundsDone[x].refund_amount), - ); - const amounts: AmountJson[] = amountsPending.concat(amountsDone); - const amount = Amounts.add( - Amounts.getZero(contractAmount.currency), - ...amounts, - ).amount; - - history.push({ - detail: { - contractTermsHash: p.contractTermsHash, - fulfillmentUrl: p.contractTerms.fulfillment_url, - merchantName: p.contractTerms.merchant.name, - refundAmount: amount, - }, - timestamp: p.lastRefundStatusTimestamp, - type: "refund", - explicit: false, + [ + Stores.currencies, + Stores.coins, + Stores.denominations, + Stores.exchanges, + Stores.proposals, + Stores.purchases, + Stores.refresh, + Stores.reserves, + Stores.tips, + Stores.withdrawalSession, + ], + async tx => { + await tx.iter(Stores.proposals).forEach(p => { + history.push({ + detail: {}, + timestamp: p.timestamp, + type: "claim-order", + explicit: false, + }); + }); + + await tx.iter(Stores.withdrawalSession).forEach(w => { + history.push({ + detail: { + withdrawalAmount: w.rawWithdrawalAmount, + }, + timestamp: w.startTimestamp, + type: "withdraw-started", + explicit: false, + }); + if (w.finishTimestamp) { + history.push({ + detail: { + withdrawalAmount: w.rawWithdrawalAmount, + }, + timestamp: w.finishTimestamp, + type: "withdraw-finished", + explicit: false, + }); + } + }); + + await tx.iter(Stores.purchases).forEach(p => { + history.push({ + detail: { + amount: p.contractTerms.amount, + contractTermsHash: p.contractTermsHash, + fulfillmentUrl: p.contractTerms.fulfillment_url, + merchantName: p.contractTerms.merchant.name, + }, + timestamp: p.acceptTimestamp, + type: "pay-started", + explicit: false, + }); + if (p.firstSuccessfulPayTimestamp) { + history.push({ + detail: { + amount: p.contractTerms.amount, + contractTermsHash: p.contractTermsHash, + fulfillmentUrl: p.contractTerms.fulfillment_url, + merchantName: p.contractTerms.merchant.name, + }, + timestamp: p.firstSuccessfulPayTimestamp, + type: "pay-finished", + explicit: false, + }); + } + if (p.lastRefundStatusTimestamp) { + const contractAmount = Amounts.parseOrThrow(p.contractTerms.amount); + const amountsPending = Object.keys(p.refundsPending).map(x => + Amounts.parseOrThrow(p.refundsPending[x].refund_amount), + ); + const amountsDone = Object.keys(p.refundsDone).map(x => + Amounts.parseOrThrow(p.refundsDone[x].refund_amount), + ); + const amounts: AmountJson[] = amountsPending.concat(amountsDone); + const amount = Amounts.add( + Amounts.getZero(contractAmount.currency), + ...amounts, + ).amount; + + history.push({ + detail: { + contractTermsHash: p.contractTermsHash, + fulfillmentUrl: p.contractTerms.fulfillment_url, + merchantName: p.contractTerms.merchant.name, + refundAmount: amount, + }, + timestamp: p.lastRefundStatusTimestamp, + type: "refund", + explicit: false, + }); + } }); - } - } - - const reserves = await oneShotIter(ws.db, Stores.reserves).toArray(); - - for (const r of reserves) { - const reserveType = r.bankWithdrawStatusUrl ? "taler-bank" : "manual"; - history.push({ - detail: { - exchangeBaseUrl: r.exchangeBaseUrl, - requestedAmount: Amounts.toString(r.initiallyRequestedAmount), - reservePub: r.reservePub, - reserveType, - bankWithdrawStatusUrl: r.bankWithdrawStatusUrl, - }, - timestamp: r.created, - type: "reserve-created", - explicit: false, - }); - if (r.timestampConfirmed) { - history.push({ - detail: { - exchangeBaseUrl: r.exchangeBaseUrl, - requestedAmount: Amounts.toString(r.initiallyRequestedAmount), - reservePub: r.reservePub, - reserveType, - bankWithdrawStatusUrl: r.bankWithdrawStatusUrl, - }, - timestamp: r.created, - type: "reserve-confirmed", - explicit: false, + + await tx.iter(Stores.reserves).forEach(r => { + const reserveType = r.bankWithdrawStatusUrl ? "taler-bank" : "manual"; + history.push({ + detail: { + exchangeBaseUrl: r.exchangeBaseUrl, + requestedAmount: Amounts.toString(r.initiallyRequestedAmount), + reservePub: r.reservePub, + reserveType, + bankWithdrawStatusUrl: r.bankWithdrawStatusUrl, + }, + timestamp: r.created, + type: "reserve-created", + explicit: false, + }); + if (r.timestampConfirmed) { + history.push({ + detail: { + exchangeBaseUrl: r.exchangeBaseUrl, + requestedAmount: Amounts.toString(r.initiallyRequestedAmount), + reservePub: r.reservePub, + reserveType, + bankWithdrawStatusUrl: r.bankWithdrawStatusUrl, + }, + timestamp: r.created, + type: "reserve-confirmed", + explicit: false, + }); + } + }); + + await tx.iter(Stores.tips).forEach(tip => { + history.push({ + detail: { + accepted: tip.accepted, + amount: tip.amount, + merchantBaseUrl: tip.merchantBaseUrl, + tipId: tip.merchantTipId, + }, + timestamp: tip.createdTimestamp, + explicit: false, + type: "tip", + }); + }); + + await tx.iter(Stores.exchanges).forEach(exchange => { + history.push({ + type: "exchange-added", + explicit: false, + timestamp: exchange.timestampAdded, + detail: { + exchangeBaseUrl: exchange.baseUrl, + }, + }); + }); + + await tx.iter(Stores.refresh).forEach((r) => { + history.push({ + type: "refresh-started", + explicit: false, + timestamp: r.created, + detail: { + refreshSessionId: r.refreshSessionId, + }, + }); + if (r.finishedTimestamp) { + history.push({ + type: "refresh-finished", + explicit: false, + timestamp: r.finishedTimestamp, + detail: { + refreshSessionId: r.refreshSessionId, + }, + }); + } + }); - } - } - - const tips: TipRecord[] = await oneShotIter(ws.db, Stores.tips).toArray(); - for (const tip of tips) { - history.push({ - detail: { - accepted: tip.accepted, - amount: tip.amount, - merchantBaseUrl: tip.merchantBaseUrl, - tipId: tip.merchantTipId, - }, - timestamp: tip.createdTimestamp, - explicit: false, - type: "tip", - }); - } - - await oneShotIter(ws.db, Stores.exchanges).forEach(exchange => { - history.push({ - type: "exchange-added", - explicit: false, - timestamp: exchange.timestampAdded, - detail: { - exchangeBaseUrl: exchange.baseUrl, - }, - }); - }); + }, + ); history.sort((h1, h2) => Math.sign(h1.timestamp.t_ms - h2.timestamp.t_ms)); diff --git a/src/wallet-impl/pay.ts b/src/wallet-impl/pay.ts index 1375a22ca..4933098cd 100644 --- a/src/wallet-impl/pay.ts +++ b/src/wallet-impl/pay.ts @@ -343,7 +343,6 @@ async function recordConfirmPay( abortRequested: false, contractTerms: d.contractTerms, contractTermsHash: d.contractTermsHash, - payFinished: false, lastSessionId: undefined, merchantSig: d.merchantSig, payReq, @@ -359,6 +358,7 @@ async function recordConfirmPay( refundStatusRequested: false, lastRefundApplyError: undefined, refundApplyRetryInfo: initRetryInfo(), + firstSuccessfulPayTimestamp: undefined, }; await runWithWriteTransaction( @@ -405,7 +405,7 @@ export async function abortFailedPayment( if (!purchase) { throw Error("Purchase not found, unable to abort with refund"); } - if (purchase.payFinished) { + if (purchase.firstSuccessfulPayTimestamp) { throw Error("Purchase already finished, not aborting"); } if (purchase.abortDone) { @@ -465,6 +465,7 @@ async function incrementProposalRetry( pr.lastError = err; await tx.put(Stores.proposals, pr); }); + ws.notify({ type: NotificationType.ProposalOperationError }); } async function incrementPurchasePayRetry( @@ -486,6 +487,7 @@ async function incrementPurchasePayRetry( pr.lastPayError = err; await tx.put(Stores.purchases, pr); }); + ws.notify({ type: NotificationType.PayOperationError }); } async function incrementPurchaseQueryRefundRetry( @@ -507,6 +509,7 @@ async function incrementPurchaseQueryRefundRetry( pr.lastRefundStatusError = err; await tx.put(Stores.purchases, pr); }); + ws.notify({ type: NotificationType.RefundStatusOperationError }); } async function incrementPurchaseApplyRefundRetry( @@ -528,6 +531,7 @@ async function incrementPurchaseApplyRefundRetry( pr.lastRefundApplyError = err; await tx.put(Stores.purchases, pr); }); + ws.notify({ type: NotificationType.RefundApplyOperationError }); } export async function processDownloadProposal( @@ -698,7 +702,7 @@ export async function submitPay( // FIXME: properly display error throw Error("merchant payment signature invalid"); } - purchase.payFinished = true; + purchase.firstSuccessfulPayTimestamp = getTimestampNow(); purchase.lastPayError = undefined; purchase.payRetryInfo = initRetryInfo(false); const modifiedCoins: CoinRecord[] = []; @@ -1044,10 +1048,9 @@ async function acceptRefundResponse( throw Error("empty refund"); } - let numNewRefunds = 0; - await runWithWriteTransaction(ws.db, [Stores.purchases], async (tx) => { + await runWithWriteTransaction(ws.db, [Stores.purchases], async tx => { const p = await tx.get(Stores.purchases, proposalId); if (!p) { console.error("purchase not found, not adding refunds"); @@ -1080,6 +1083,9 @@ async function acceptRefundResponse( await tx.put(Stores.purchases, p); }); + ws.notify({ + type: NotificationType.RefundQueried, + }); if (numNewRefunds > 0) { await processPurchaseApplyRefund(ws, proposalId); } @@ -1099,7 +1105,6 @@ async function startRefundQuery( return false; } if (p.refundStatusRequested) { - } p.refundStatusRequested = true; p.lastRefundStatusError = undefined; @@ -1113,6 +1118,10 @@ async function startRefundQuery( return; } + ws.notify({ + type: NotificationType.RefundStarted, + }); + await processPurchaseQueryRefund(ws, proposalId); } @@ -1169,7 +1178,7 @@ async function processPurchasePayImpl( return; } logger.trace(`processing purchase pay ${proposalId}`); - if (purchase.payFinished) { + if (purchase.firstSuccessfulPayTimestamp) { return; } await submitPay(ws, proposalId, purchase.lastSessionId); diff --git a/src/wallet-impl/pending.ts b/src/wallet-impl/pending.ts index 169c7e291..b0ae71f6b 100644 --- a/src/wallet-impl/pending.ts +++ b/src/wallet-impl/pending.ts @@ -353,7 +353,7 @@ async function gatherPurchasePending( onlyDue: boolean = false, ): Promise { await tx.iter(Stores.purchases).forEach((pr) => { - if (!pr.payFinished) { + if (!pr.firstSuccessfulPayTimestamp) { resp.nextRetryDelay = updateRetryDelay( resp.nextRetryDelay, now, diff --git a/src/wallet-impl/refresh.ts b/src/wallet-impl/refresh.ts index a3b48919d..93be1435d 100644 --- a/src/wallet-impl/refresh.ts +++ b/src/wallet-impl/refresh.ts @@ -312,6 +312,7 @@ async function incrementRefreshRetry( r.lastError = err; await tx.put(Stores.refresh, r); }); + ws.notify({ type: NotificationType.RefreshOperationError }); } diff --git a/src/wallet-impl/reserves.ts b/src/wallet-impl/reserves.ts index f00956b46..8a700bebc 100644 --- a/src/wallet-impl/reserves.ts +++ b/src/wallet-impl/reserves.ts @@ -342,6 +342,7 @@ async function incrementReserveRetry( r.lastError = err; await tx.put(Stores.reserves, r); }); + ws.notify({ type: NotificationType.ReserveOperationError }); } /** @@ -606,6 +607,10 @@ async function depleteReserve( if (success) { console.log("processing new withdraw session"); + ws.notify({ + type: NotificationType.WithdrawSessionCreated, + withdrawSessionId: withdrawalSessionId, + }); await processWithdrawSession(ws, withdrawalSessionId); } else { console.trace("withdraw session already existed"); diff --git a/src/wallet-impl/tip.ts b/src/wallet-impl/tip.ts index 3ae931d45..9cfaed930 100644 --- a/src/wallet-impl/tip.ts +++ b/src/wallet-impl/tip.ts @@ -18,7 +18,7 @@ import { oneShotGet, oneShotPut, oneShotMutate, runWithWriteTransaction } from "../util/query"; import { InternalWalletState } from "./state"; import { parseTipUri } from "../util/taleruri"; -import { TipStatus, getTimestampNow, OperationError } from "../walletTypes"; +import { TipStatus, getTimestampNow, OperationError, NotificationType } from "../walletTypes"; import { TipPickupGetResponse, TipPlanchetDetail, TipResponse } from "../talerTypes"; import * as Amounts from "../util/amounts"; import { Stores, PlanchetRecord, WithdrawalSessionRecord, initRetryInfo, updateRetryInfoTimeout } from "../dbTypes"; @@ -122,6 +122,7 @@ async function incrementTipRetry( t.lastError = err; await tx.put(Stores.tips, t); }); + ws.notify({ type: NotificationType.TipOperationError }); } export async function processTip( diff --git a/src/wallet-impl/withdraw.ts b/src/wallet-impl/withdraw.ts index 3122a463c..5d89f64a9 100644 --- a/src/wallet-impl/withdraw.ts +++ b/src/wallet-impl/withdraw.ts @@ -260,17 +260,17 @@ async function processPlanchet( let withdrawSessionFinished = false; let reserveDepleted = false; - await runWithWriteTransaction( + const success = await runWithWriteTransaction( ws.db, [Stores.coins, Stores.withdrawalSession, Stores.reserves], async tx => { const ws = await tx.get(Stores.withdrawalSession, withdrawalSessionId); if (!ws) { - return; + return false; } if (ws.withdrawn[coinIdx]) { // Already withdrawn - return; + return false; } ws.withdrawn[coinIdx] = true; ws.lastCoinErrors[coinIdx] = undefined; @@ -301,9 +301,16 @@ async function processPlanchet( } } await tx.add(Stores.coins, coin); + return true; }, ); + if (success) { + ws.notify( { + type: NotificationType.CoinWithdrawn, + } ); + } + if (withdrawSessionFinished) { ws.notify({ type: NotificationType.WithdrawSessionFinished, @@ -503,6 +510,7 @@ async function incrementWithdrawalRetry( wsr.lastError = err; await tx.put(Stores.withdrawalSession, wsr); }); + ws.notify({ type: NotificationType.WithdrawOperationError }); } export async function processWithdrawSession( diff --git a/src/walletTypes.ts b/src/walletTypes.ts index f27970330..6e246c682 100644 --- a/src/walletTypes.ts +++ b/src/walletTypes.ts @@ -506,6 +506,7 @@ export interface PendingPayOperation { } export const enum NotificationType { + CoinWithdrawn = "coin-withdrawn", ProposalAccepted = "proposal-accepted", ProposalDownloaded = "proposal-downloaded", RefundsSubmitted = "refunds-submitted", @@ -518,9 +519,21 @@ export const enum NotificationType { ReserveUpdated = "reserve-updated", ReserveConfirmed = "reserve-confirmed", ReserveDepleted = "reserve-depleted", + WithdrawSessionCreated = "withdraw-session-created", WithdrawSessionFinished = "withdraw-session-finished", WaitingForRetry = "waiting-for-retry", + RefundStarted = "refund-started", + RefundQueried = "refund-queried", RefundFinished = "refund-finished", + ExchangeOperationError = "exchange-operation-error", + RefreshOperationError = "refresh-operation-error", + RefundApplyOperationError = "refund-apply-error", + RefundStatusOperationError = "refund-status-error", + ProposalOperationError = "proposal-error", + TipOperationError = "tip-error", + PayOperationError = "pay-error", + WithdrawOperationError = "withdraw-error", + ReserveOperationError = "reserve-error", } export interface ProposalAcceptedNotification { @@ -528,6 +541,18 @@ export interface ProposalAcceptedNotification { proposalId: string; } +export interface CoinWithdrawnNotification { + type: NotificationType.CoinWithdrawn; +} + +export interface RefundStartedNotification { + type: NotificationType.RefundStarted; +} + +export interface RefundQueriedNotification { + type: NotificationType.RefundQueried; +} + export interface ProposalDownloadedNotification { type: NotificationType.ProposalDownloaded; proposalId: string; @@ -570,6 +595,11 @@ export interface ReserveConfirmedNotification { type: NotificationType.ReserveConfirmed; } +export interface WithdrawSessionCreatedNotification { + type: NotificationType.WithdrawSessionCreated; + withdrawSessionId: string; +} + export interface WithdrawSessionFinishedNotification { type: NotificationType.WithdrawSessionFinished; withdrawSessionId: string; @@ -590,7 +620,52 @@ export interface RefundFinishedNotification { type: NotificationType.RefundFinished; } +export interface ExchangeOperationErrorNotification { + type: NotificationType.ExchangeOperationError; +} + +export interface RefreshOperationErrorNotification { + type: NotificationType.RefreshOperationError; +} + +export interface RefundStatusOperationErrorNotification { + type: NotificationType.RefundStatusOperationError; +} + +export interface RefundApplyOperationErrorNotification { + type: NotificationType.RefundApplyOperationError; +} + +export interface PayOperationErrorNotification { + type: NotificationType.PayOperationError; +} + +export interface ProposalOperationErrorNotification { + type: NotificationType.ProposalOperationError; +} + +export interface TipOperationErrorNotification { + type: NotificationType.TipOperationError; +} + +export interface WithdrawOperationErrorNotification { + type: NotificationType.WithdrawOperationError; +} + +export interface ReserveOperationErrorNotification { + type: NotificationType.ReserveOperationError; +} + export type WalletNotification = + | WithdrawOperationErrorNotification + | ReserveOperationErrorNotification + | ExchangeOperationErrorNotification + | RefreshOperationErrorNotification + | RefundStatusOperationErrorNotification + | RefundApplyOperationErrorNotification + | ProposalOperationErrorNotification + | PayOperationErrorNotification + | TipOperationErrorNotification | ProposalAcceptedNotification | ProposalDownloadedNotification | RefundsSubmittedNotification @@ -605,7 +680,11 @@ export type WalletNotification = | WithdrawSessionFinishedNotification | ReserveDepletedNotification | WaitingForRetryNotification - | RefundFinishedNotification; + | RefundStartedNotification + | RefundFinishedNotification + | RefundQueriedNotification + | WithdrawSessionCreatedNotification + | CoinWithdrawnNotification; export interface OperationError { type: string; -- cgit v1.2.3