From 553da649902f71d5ca34c9a6289ab6b1ef0ba7cb Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Wed, 20 Nov 2019 19:48:43 +0100 Subject: [PATCH] WIP: simplify DB queries and error handling --- package.json | 2 +- packages/idb-bridge/.vscode/settings.json | 3 +- src/dbTypes.ts | 102 +- src/headless/clk.ts | 64 +- src/headless/taler-wallet-cli.ts | 108 +- src/helpers.ts | 14 + src/logging.ts | 351 ----- src/query.ts | 1182 +++++------------ src/wallet.ts | 1412 +++++++++------------ src/walletTypes.ts | 58 +- src/webex/messages.ts | 16 - src/webex/pages/error.html | 18 - src/webex/pages/error.tsx | 129 -- src/webex/pages/logs.html | 27 - src/webex/pages/logs.tsx | 86 -- src/webex/renderHtml.tsx | 6 +- src/webex/wxApi.ts | 32 - src/webex/wxBackend.ts | 42 - tsconfig.json | 3 - webpack.config.js | 2 - yarn.lock | 8 +- 21 files changed, 1178 insertions(+), 2487 deletions(-) delete mode 100644 src/logging.ts delete mode 100644 src/webex/pages/error.html delete mode 100644 src/webex/pages/error.tsx delete mode 100644 src/webex/pages/logs.html delete mode 100644 src/webex/pages/logs.tsx diff --git a/package.json b/package.json index 8e7a5b355..d8511c409 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "@types/urijs": "^1.19.3", "axios": "^0.19.0", "commander": "^3.0.1", - "idb-bridge": "^0.0.10", + "idb-bridge": "^0.0.11", "qrcode-generator": "^1.4.3", "source-map-support": "^0.5.12", "urijs": "^1.18.10" diff --git a/packages/idb-bridge/.vscode/settings.json b/packages/idb-bridge/.vscode/settings.json index ff30c4464..ec71f9aae 100644 --- a/packages/idb-bridge/.vscode/settings.json +++ b/packages/idb-bridge/.vscode/settings.json @@ -1,3 +1,4 @@ { - "editor.tabSize": 2 + "editor.tabSize": 2, + "typescript.tsdk": "node_modules/typescript/lib" } \ No newline at end of file diff --git a/src/dbTypes.ts b/src/dbTypes.ts index 28893b8eb..0d54069ec 100644 --- a/src/dbTypes.ts +++ b/src/dbTypes.ts @@ -36,6 +36,7 @@ import { } from "./talerTypes"; import { Index, Store } from "./query"; +import { Timestamp, OperationError } from "./walletTypes"; /** * Current database version, should be incremented @@ -310,13 +311,10 @@ export class DenominationRecord { } /** - * Exchange record as stored in the wallet's database. + * Details about the exchange that we only know after + * querying /keys and /wire. */ -export interface ExchangeRecord { - /** - * Base url of the exchange. - */ - baseUrl: string; +export interface ExchangeDetails { /** * Master public key of the exchange. */ @@ -331,22 +329,60 @@ export interface ExchangeRecord { */ currency: string; - /** - * Timestamp for last update. - */ - lastUpdateTime: number; - - /** - * When did we actually use this exchange last (in milliseconds). If we - * never used the exchange for anything but just updated its info, this is - * set to 0. (Currently only updated when reserves are created.) - */ - lastUsedTime: number; - /** * Last observed protocol version. */ - protocolVersion?: string; + protocolVersion: string; + + /** + * Timestamp for last update. + */ + lastUpdateTime: Timestamp; +} + +export enum ExchangeUpdateStatus { + NONE = "none", + FETCH_KEYS = "fetch_keys", + FETCH_WIRE = "fetch_wire", +} + +export interface ExchangeBankAccount { + url: string; +} + +export interface ExchangeWireInfo { + feesForType: { [wireMethod: string]: WireFee[] }; + accounts: ExchangeBankAccount[]; +} + +/** + * Exchange record as stored in the wallet's database. + */ +export interface ExchangeRecord { + /** + * Base url of the exchange. + */ + baseUrl: string; + + /** + * Details, once known. + */ + details: ExchangeDetails | undefined; + + /** + * Mapping from wire method type to the wire fee. + */ + wireInfo: ExchangeWireInfo | undefined; + + /** + * Time when the update to the exchange has been started or + * undefined if no update is in progress. + */ + updateStarted: Timestamp | undefined; + + updateStatus: ExchangeUpdateStatus; + + lastError?: OperationError; } /** @@ -554,21 +590,6 @@ export class ProposalDownloadRecord { static checked: (obj: any) => ProposalDownloadRecord; } -/** - * Wire fees for an exchange. - */ -export interface ExchangeWireFeesRecord { - /** - * Base URL of the exchange. - */ - exchangeBaseUrl: string; - - /** - * Mapping from wire method type to the wire fee. - */ - feesForType: { [wireMethod: string]: WireFee[] }; -} - /** * Status of a tip we got from a merchant. */ @@ -931,12 +952,6 @@ export namespace Stores { constructor() { super("exchanges", { keyPath: "baseUrl" }); } - - pubKeyIndex = new Index( - this, - "pubKeyIndex", - "masterPublicKey", - ); } class CoinsStore extends Store { @@ -1034,12 +1049,6 @@ export namespace Stores { } } - class ExchangeWireFeesStore extends Store { - constructor() { - super("exchangeWireFees", { keyPath: "exchangeBaseUrl" }); - } - } - class ReservesStore extends Store { constructor() { super("reserves", { keyPath: "reserve_pub" }); @@ -1094,7 +1103,6 @@ export namespace Stores { export const config = new ConfigStore(); export const currencies = new CurrenciesStore(); export const denominations = new DenominationsStore(); - export const exchangeWireFees = new ExchangeWireFeesStore(); export const exchanges = new ExchangeStore(); export const precoins = new Store("precoins", { keyPath: "coinPub", diff --git a/src/headless/clk.ts b/src/headless/clk.ts index 642a1bef3..f66d609e8 100644 --- a/src/headless/clk.ts +++ b/src/headless/clk.ts @@ -20,7 +20,6 @@ import process = require("process"); import path = require("path"); import readline = require("readline"); -import { symlinkSync } from "fs"; class Converter {} @@ -54,6 +53,7 @@ interface ArgumentDef { name: string; conv: Converter; args: ArgumentArgs; + required: boolean; } interface SubcommandDef { @@ -181,7 +181,7 @@ export class CommandGroup { return this as any; } - argument( + requiredArgument( name: N, conv: Converter, args: ArgumentArgs = {}, @@ -190,6 +190,22 @@ export class CommandGroup { args: args, conv: conv, name: name as string, + required: true, + }; + this.arguments.push(argDef); + return this as any; + } + + maybeArgument( + name: N, + conv: Converter, + args: ArgumentArgs = {}, + ): CommandGroup> { + const argDef: ArgumentDef = { + args: args, + conv: conv, + name: name as string, + required: false, }; this.arguments.push(argDef); return this as any; @@ -401,10 +417,25 @@ export class CommandGroup { process.exit(-1); throw Error("not reached"); } + myArgs[d.name] = unparsedArgs[i]; posArgIndex++; } } + for (let i = posArgIndex; i < this.arguments.length; i++) { + const d = this.arguments[i]; + const n = this.name ?? progname; + if (d.required) { + if (d.args.default !== undefined) { + myArgs[d.name] = d.args.default; + } else { + console.error(`error: missing positional argument '${d.name}' for ${n}`); + process.exit(-1); + throw Error("not reached"); + } + } + } + for (let option of this.options) { if (option.isFlag == false && option.required == true) { if (!foundOptions[option.name]) { @@ -433,9 +464,7 @@ export class CommandGroup { unparsedArgs.slice(i + 1), parsedArgs, ); - } - - if (this.myAction) { + } else if (this.myAction) { this.myAction(parsedArgs); } else { this.printHelp(progname, parents); @@ -513,18 +542,35 @@ export class Program { } /** - * Add a positional argument to the program. + * Add a required positional argument to the program. */ - argument( + requiredArgument( name: N, conv: Converter, args: ArgumentArgs = {}, ): Program> { - this.mainCommand.argument(name, conv, args); + this.mainCommand.requiredArgument(name, conv, args); + return this as any; + } + + /** + * Add an optional argument to the program. + */ + maybeArgument( + name: N, + conv: Converter, + args: ArgumentArgs = {}, + ): Program> { + this.mainCommand.maybeArgument(name, conv, args); return this as any; } } +export type GetArgType = + T extends Program ? AT : + T extends CommandGroup ? AT : + any; + export function program( argKey: PN, args: ProgramArgs = {}, @@ -532,6 +578,8 @@ export function program( return new Program(argKey as string, args); } + + export function prompt(question: string): Promise { const stdinReadline = readline.createInterface({ input: process.stdin, diff --git a/src/headless/taler-wallet-cli.ts b/src/headless/taler-wallet-cli.ts index 41f68319a..06235d0b4 100644 --- a/src/headless/taler-wallet-cli.ts +++ b/src/headless/taler-wallet-cli.ts @@ -97,6 +97,28 @@ const walletCli = clk help: "Enable verbose output.", }); +type WalletCliArgsType = clk.GetArgType; + +async function withWallet( + walletCliArgs: WalletCliArgsType, + f: (w: Wallet) => Promise, +): Promise { + applyVerbose(walletCliArgs.wallet.verbose); + const wallet = await getDefaultNodeWallet({ + persistentStoragePath: walletDbPath, + }); + try { + await wallet.fillDefaults(); + const ret = await f(wallet); + return ret; + } catch (e) { + console.error("caught exception:", e); + process.exit(1); + } finally { + wallet.stop(); + } +} + walletCli .subcommand("testPayCmd", "test-pay", { help: "create contract and pay" }) .requiredOption("amount", ["-a", "--amount"], clk.STRING) @@ -135,15 +157,11 @@ walletCli walletCli .subcommand("", "balance", { help: "Show wallet balance." }) .action(async args => { - applyVerbose(args.wallet.verbose); console.log("balance command called"); - const wallet = await getDefaultNodeWallet({ - persistentStoragePath: walletDbPath, + withWallet(args, async (wallet) => { + const balance = await wallet.getBalances(); + console.log(JSON.stringify(balance, undefined, 2)); }); - console.log("got wallet"); - const balance = await wallet.getBalances(); - console.log(JSON.stringify(balance, undefined, 2)); - process.exit(0); }); walletCli @@ -153,29 +171,19 @@ walletCli .requiredOption("limit", ["--limit"], clk.STRING) .requiredOption("contEvt", ["--continue-with"], clk.STRING) .action(async args => { - applyVerbose(args.wallet.verbose); - console.log("history command called"); - const wallet = await getDefaultNodeWallet({ - persistentStoragePath: walletDbPath, + withWallet(args, async (wallet) => { + const history = await wallet.getHistory(); + console.log(JSON.stringify(history, undefined, 2)); }); - console.log("got wallet"); - const history = await wallet.getHistory(); - console.log(JSON.stringify(history, undefined, 2)); - process.exit(0); }); walletCli .subcommand("", "pending", { help: "Show pending operations." }) .action(async args => { - applyVerbose(args.wallet.verbose); - console.log("history command called"); - const wallet = await getDefaultNodeWallet({ - persistentStoragePath: walletDbPath, + withWallet(args, async (wallet) => { + const pending = await wallet.getPendingOperations(); + console.log(JSON.stringify(pending, undefined, 2)); }); - console.log("got wallet"); - const pending = await wallet.getPendingOperations(); - console.log(JSON.stringify(pending, undefined, 2)); - process.exit(0); }); async function asyncSleep(milliSeconds: number): Promise { @@ -184,6 +192,16 @@ async function asyncSleep(milliSeconds: number): Promise { }); } +walletCli + .subcommand("runPendingOpt", "run-pending", { + help: "Run pending operations." + }) + .action(async (args) => { + withWallet(args, async (wallet) => { + await wallet.processPending(); + }); + }); + walletCli .subcommand("testMerchantQrcodeCmd", "test-merchant-qrcode") .requiredOption("amount", ["-a", "--amount"], clk.STRING, { @@ -279,7 +297,7 @@ walletCli walletCli .subcommand("withdrawUriCmd", "withdraw-uri") - .argument("withdrawUri", clk.STRING) + .requiredArgument("withdrawUri", clk.STRING) .action(async args => { applyVerbose(args.wallet.verbose); const cmdArgs = args.withdrawUriCmd; @@ -318,7 +336,7 @@ walletCli walletCli .subcommand("tipUriCmd", "tip-uri") - .argument("uri", clk.STRING) + .requiredArgument("uri", clk.STRING) .action(async args => { applyVerbose(args.wallet.verbose); const tipUri = args.tipUriCmd.uri; @@ -334,7 +352,7 @@ walletCli walletCli .subcommand("refundUriCmd", "refund-uri") - .argument("uri", clk.STRING) + .requiredArgument("uri", clk.STRING) .action(async args => { applyVerbose(args.wallet.verbose); const refundUri = args.refundUriCmd.uri; @@ -346,20 +364,38 @@ walletCli wallet.stop(); }); -const exchangesCli = walletCli - .subcommand("exchangesCmd", "exchanges", { - help: "Manage exchanges." - }); - -exchangesCli.subcommand("exchangesListCmd", "list", { - help: "List known exchanges." +const exchangesCli = walletCli.subcommand("exchangesCmd", "exchanges", { + help: "Manage exchanges.", }); -exchangesCli.subcommand("exchangesListCmd", "update"); +exchangesCli + .subcommand("exchangesListCmd", "list", { + help: "List known exchanges.", + }) + .action(async args => { + console.log("Listing exchanges ..."); + withWallet(args, async (wallet) => { + const exchanges = await wallet.getExchanges(); + console.log("exchanges", exchanges); + }); + }); + +exchangesCli + .subcommand("exchangesUpdateCmd", "update", { + help: "Update or add an exchange by base URL.", + }) + .requiredArgument("url", clk.STRING, { + help: "Base URL of the exchange.", + }) + .action(async args => { + withWallet(args, async (wallet) => { + const res = await wallet.updateExchangeFromUrl(args.exchangesUpdateCmd.url); + }); + }); walletCli .subcommand("payUriCmd", "pay-uri") - .argument("url", clk.STRING) + .requiredArgument("url", clk.STRING) .flag("autoYes", ["-y", "--yes"]) .action(async args => { applyVerbose(args.wallet.verbose); @@ -374,7 +410,7 @@ walletCli }); const testCli = walletCli.subcommand("testingArgs", "testing", { - help: "Subcommands for testing GNU Taler deployments." + help: "Subcommands for testing GNU Taler deployments.", }); testCli diff --git a/src/helpers.ts b/src/helpers.ts index 7cd176498..a063db169 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -25,6 +25,7 @@ import { AmountJson } from "./amounts"; import * as Amounts from "./amounts"; import URI = require("urijs"); +import { Timestamp } from "./walletTypes"; /** * Show an amount in a form suitable for the user. @@ -125,6 +126,19 @@ export function getTalerStampSec(stamp: string): number | null { return parseInt(m[1], 10); } +/** + * Extract a timestamp from a Taler timestamp string. + */ +export function extractTalerStamp(stamp: string): Timestamp | undefined { + const m = stamp.match(/\/?Date\(([0-9]*)\)\/?/); + if (!m || !m[1]) { + return undefined; + } + return { + t_ms: parseInt(m[1], 10) * 1000, + }; +} + /** * Check if a timestamp is in the right format. */ diff --git a/src/logging.ts b/src/logging.ts deleted file mode 100644 index 4e7b60b93..000000000 --- a/src/logging.ts +++ /dev/null @@ -1,351 +0,0 @@ -/* - This file is part of TALER - (C) 2016 Inria - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - TALER; see the file COPYING. If not, see - */ - -/** - * Configurable logging. Allows to log persistently to a database. - */ - -import { - QueryRoot, - Store, -} from "./query"; -import { openPromise } from "./promiseUtils"; - -/** - * Supported log levels. - */ -export type Level = "error" | "debug" | "info" | "warn"; - -// Right now, our debug/info/warn/debug loggers just use the console based -// loggers. This might change in the future. - -function makeInfo() { - return console.info.bind(console, "%o"); -} - -function makeWarn() { - return console.warn.bind(console, "%o"); -} - -function makeError() { - return console.error.bind(console, "%o"); -} - -function makeDebug() { - return console.log.bind(console, "%o"); -} - -/** - * Log a message using the configurable logger. - */ -export async function log(msg: string, level: Level = "info"): Promise { - const ci = getCallInfo(2); - return record(level, msg, undefined, ci.file, ci.line, ci.column); -} - -function getCallInfo(level: number) { - // see https://github.com/v8/v8/wiki/Stack-Trace-API - const stack = Error().stack; - if (!stack) { - return unknownFrame; - } - const lines = stack.split("\n"); - return parseStackLine(lines[level + 1]); -} - -interface Frame { - column?: number; - file?: string; - line?: number; - method?: string; -} - -const unknownFrame: Frame = { - column: 0, - file: "(unknown)", - line: 0, - method: "(unknown)", -}; - -/** - * Adapted from https://github.com/errwischt/stacktrace-parser. - */ -function parseStackLine(stackLine: string): Frame { - // tslint:disable-next-line:max-line-length - const chrome = /^\s*at (?:(?:(?:Anonymous function)?|((?:\[object object\])?\S+(?: \[as \S+\])?)) )?\(?((?:file|http|https):.*?):(\d+)(?::(\d+))?\)?\s*$/i; - const gecko = /^(?:\s*([^@]*)(?:\((.*?)\))?@)?(\S.*?):(\d+)(?::(\d+))?\s*$/i; - const node = /^\s*at (?:((?:\[object object\])?\S+(?: \[as \S+\])?) )?\(?(.*?):(\d+)(?::(\d+))?\)?\s*$/i; - let parts; - - parts = gecko.exec(stackLine); - if (parts) { - const f: Frame = { - column: parts[5] ? +parts[5] : undefined, - file: parts[3], - line: +parts[4], - method: parts[1] || "(unknown)", - }; - return f; - } - - parts = chrome.exec(stackLine); - if (parts) { - const f: Frame = { - column: parts[4] ? +parts[4] : undefined, - file: parts[2], - line: +parts[3], - method: parts[1] || "(unknown)", - }; - return f; - } - - parts = node.exec(stackLine); - if (parts) { - const f: Frame = { - column: parts[4] ? +parts[4] : undefined, - file: parts[2], - line: +parts[3], - method: parts[1] || "(unknown)", - }; - return f; - } - - return unknownFrame; -} - - -let db: IDBDatabase|undefined; - -/** - * A structured log entry as stored in the database. - */ -export interface LogEntry { - /** - * Soure code column where the error occured. - */ - col?: number; - /** - * Additional detail for the log statement. - */ - detail?: string; - /** - * Id of the log entry, used as primary - * key for the database. - */ - id?: number; - /** - * Log level, see [[Level}}. - */ - level: string; - /** - * Line where the log was created from. - */ - line?: number; - /** - * The actual log message. - */ - msg: string; - /** - * The source file where the log enctry - * was created from. - */ - source?: string; - /** - * Time when the log entry was created. - */ - timestamp: number; -} - -/** - * Get all logs. Only use for debugging, since this returns all logs ever made - * at once without pagination. - */ -export async function getLogs(): Promise { - if (!db) { - db = await openLoggingDb(); - } - return await new QueryRoot(db).iter(logsStore).toArray(); -} - -/** - * The barrier ensures that only one DB write is scheduled against the log db - * at the same time, so that the DB can stay responsive. This is a bit of a - * design problem with IndexedDB, it doesn't guarantee fairness. - */ -let barrier: any; - -/** - * Record an exeption in the log. - */ -export async function recordException(msg: string, e: any): Promise { - let stack: string|undefined; - let frame: Frame|undefined; - try { - stack = e.stack; - if (stack) { - const lines = stack.split("\n"); - frame = parseStackLine(lines[1]); - } - } catch (e) { - // ignore - } - if (!frame) { - frame = unknownFrame; - } - return record("error", e.toString(), stack, frame.file, frame.line, frame.column); -} - - -/** - * Cache for reports. Also used when something is so broken that we can't even - * access the database. - */ -const reportCache: { [reportId: string]: any } = {}; - - -/** - * Get a UUID that does not use cryptographically secure randomness. - * Formatted as RFC4122 version 4 UUID. - */ -function getInsecureUuid() { - return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c: string) => { - const r = Math.random() * 16 | 0; - const v = c === "x" ? r : (r & 0x3 | 0x8); - return v.toString(16); - }); -} - - -/** - * Store a report and return a unique identifier to retrieve it later. - */ -export async function storeReport(report: any): Promise { - const uid = getInsecureUuid(); - reportCache[uid] = report; - return uid; -} - - -/** - * Retrieve a report by its unique identifier. - */ -export async function getReport(reportUid: string): Promise { - return reportCache[reportUid]; -} - - -/** - * Record a log entry in the database. - */ -export async function record(level: Level, - msg: string, - detail?: string, - source?: string, - line?: number, - col?: number): Promise { - if (typeof indexedDB === "undefined") { - console.log("can't access DB for logging in this context"); - console.log("log was", { level, msg, detail, source, line, col }); - return; - } - - let myBarrier: any; - - if (barrier) { - const p = barrier.promise; - myBarrier = barrier = openPromise(); - await p; - } else { - myBarrier = barrier = openPromise(); - } - - try { - if (!db) { - db = await openLoggingDb(); - } - - const count = await new QueryRoot(db).count(logsStore); - - if (count > 1000) { - await new QueryRoot(db).deleteIf(logsStore, (e, i) => (i < 200)); - } - - const entry: LogEntry = { - col, - detail, - level, - line, - msg, - source, - timestamp: new Date().getTime(), - }; - await new QueryRoot(db).put(logsStore, entry); - } finally { - await Promise.resolve().then(() => myBarrier.resolve()); - } -} - -const loggingDbVersion = 2; - -const logsStore: Store = new Store("logs"); - -/** - * Get a handle to the IndexedDB used to store - * logs. - */ -export function openLoggingDb(): Promise { - return new Promise((resolve, reject) => { - const req = indexedDB.open("taler-logging", loggingDbVersion); - req.onerror = (e) => { - reject(e); - }; - req.onsuccess = (e) => { - resolve(req.result); - }; - req.onupgradeneeded = (e) => { - const resDb = req.result; - if (e.oldVersion !== 0) { - try { - resDb.deleteObjectStore("logs"); - } catch (e) { - console.error(e); - } - } - resDb.createObjectStore("logs", { keyPath: "id", autoIncrement: true }); - resDb.createObjectStore("reports", { keyPath: "uid", autoIncrement: false }); - }; - }); -} - -/** - * Log a message at severity info. - */ -export const info = makeInfo(); - -/** - * Log a message at severity debug. - */ -export const debug = makeDebug(); - -/** - * Log a message at severity warn. - */ -export const warn = makeWarn(); - -/** - * Log a message at severity error. - */ -export const error = makeError(); diff --git a/src/query.ts b/src/query.ts index 7c9390467..7d03cfea8 100644 --- a/src/query.ts +++ b/src/query.ts @@ -1,3 +1,5 @@ +import { openPromise } from "./promiseUtils"; + /* This file is part of TALER (C) 2016 GNUnet e.V. @@ -20,9 +22,6 @@ * @author Florian Dold */ - import { openPromise } from "./promiseUtils"; -import { join } from "path"; - /** * Result of an inner join. */ @@ -63,6 +62,298 @@ export interface IndexOptions { multiEntry?: boolean; } +function requestToPromise(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => { + resolve(req.result); + }; + req.onerror = () => { + reject(req.error); + }; + }); +} + +export function oneShotGet( + db: IDBDatabase, + store: Store, + key: any, +): Promise { + const tx = db.transaction([store.name], "readonly"); + const req = tx.objectStore(store.name).get(key); + return requestToPromise(req); +} + +export function oneShotGetIndexed( + db: IDBDatabase, + index: Index, + key: any, +): Promise { + const tx = db.transaction([index.storeName], "readonly"); + const req = tx.objectStore(index.storeName).index(index.indexName).get(key); + return requestToPromise(req); +} + +export function oneShotPut( + db: IDBDatabase, + store: Store, + value: T, + key?: any, +): Promise { + const tx = db.transaction([store.name], "readwrite"); + const req = tx.objectStore(store.name).put(value, key); + return requestToPromise(req); +} + +function applyMutation(req: IDBRequest, f: (x: T) => T | undefined): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => { + const cursor = req.result; + if (cursor) { + const val = cursor.value(); + const modVal = f(val); + if (modVal !== undefined && modVal !== null) { + const req2: IDBRequest = cursor.update(modVal); + req2.onerror = () => { + reject(req2.error); + }; + req2.onsuccess = () => { + cursor.continue(); + }; + } else { + cursor.continue(); + } + } else { + resolve(); + } + }; + req.onerror = () => { + reject(req.error); + }; + }); +} + +export function oneShotMutate( + db: IDBDatabase, + store: Store, + key: any, + f: (x: T) => T | undefined, +): Promise { + const tx = db.transaction([store.name], "readwrite"); + const req = tx.objectStore(store.name).openCursor(key); + return applyMutation(req, f); +} + +type CursorResult = CursorEmptyResult | CursorValueResult; + +interface CursorEmptyResult { + hasValue: false; +} + +interface CursorValueResult { + hasValue: true; + value: T; +} + +class ResultStream { + private currentPromise: Promise; + private gotCursorEnd: boolean = false; + private awaitingResult: boolean = false; + + constructor(private req: IDBRequest) { + this.awaitingResult = true; + let p = openPromise(); + this.currentPromise = p.promise; + req.onsuccess = () => { + if (!this.awaitingResult) { + throw Error("BUG: invariant violated"); + } + const cursor = req.result; + if (cursor) { + this.awaitingResult = false; + p.resolve(); + p = openPromise(); + this.currentPromise = p.promise; + } else { + this.gotCursorEnd = true; + p.resolve(); + } + }; + req.onerror = () => { + p.reject(req.error); + }; + } + + async toArray(): Promise { + const arr: T[] = []; + while (true) { + const x = await this.next(); + if (x.hasValue) { + arr.push(x.value); + } else { + break; + } + } + return arr; + } + + async map(f: (x: T) => R): Promise { + const arr: R[] = []; + while (true) { + const x = await this.next(); + if (x.hasValue) { + arr.push(f(x.value)); + } else { + break; + } + } + return arr; + } + + async forEach(f: (x: T) => void): Promise { + while (true) { + const x = await this.next(); + if (x.hasValue) { + f(x.value); + } else { + break; + } + } + } + + async filter(f: (x: T) => boolean): Promise { + const arr: T[] = []; + while (true) { + const x = await this.next(); + if (x.hasValue) { + if (f(x.value)) { + arr.push(x.value) + } + } else { + break; + } + } + return arr; + } + + async next(): Promise> { + if (this.gotCursorEnd) { + return { hasValue: false }; + } + if (!this.awaitingResult) { + const cursor = this.req.result; + if (!cursor) { + throw Error("assertion failed"); + } + this.awaitingResult = true; + cursor.continue(); + } + await this.currentPromise; + if (this.gotCursorEnd) { + return { hasValue: false }; + } + const cursor = this.req.result; + if (!cursor) { + throw Error("assertion failed"); + } + return { hasValue: true, value: cursor.value }; + } +} + +export function oneShotIter( + db: IDBDatabase, + store: Store +): ResultStream { + const tx = db.transaction([store.name], "readonly"); + const req = tx.objectStore(store.name).openCursor(); + return new ResultStream(req); +} + +export function oneShotIterIndex( + db: IDBDatabase, + index: Index, + query?: any, +): ResultStream { + const tx = db.transaction([index.storeName], "readonly"); + const req = tx.objectStore(index.storeName).index(index.indexName).openCursor(query); + return new ResultStream(req); +} + +class TransactionHandle { + constructor(private tx: IDBTransaction) {} + + put(store: Store, value: T, key?: any): Promise { + const req = this.tx.objectStore(store.name).put(value, key); + return requestToPromise(req); + } + + add(store: Store, value: T, key?: any): Promise { + const req = this.tx.objectStore(store.name).add(value, key); + return requestToPromise(req); + } + + get(store: Store, key: any): Promise { + const req = this.tx.objectStore(store.name).get(key); + return requestToPromise(req); + } + + iter(store: Store, key?: any): ResultStream { + const req = this.tx.objectStore(store.name).openCursor(key); + return new ResultStream(req); + } + + delete(store: Store, key: any): Promise { + const req = this.tx.objectStore(store.name).delete(key); + return requestToPromise(req); + } + + mutate(store: Store, key: any, f: (x: T) => T | undefined) { + const req = this.tx.objectStore(store.name).openCursor(key); + return applyMutation(req, f); + } +} + +export function runWithWriteTransaction( + db: IDBDatabase, + stores: Store[], + f: (t: TransactionHandle) => Promise, +): Promise { + return new Promise((resolve, reject) => { + const storeName = stores.map(x => x.name); + const tx = db.transaction(storeName, "readwrite"); + let funResult: any = undefined; + let gotFunResult: boolean = false; + tx.onerror = () => { + console.error("error in transaction:", tx.error); + reject(tx.error); + }; + tx.oncomplete = () => { + // This is a fatal error: The transaction completed *before* + // the transaction function returned. Likely, the transaction + // function waited on a promise that is *not* resolved in the + // microtask queue, thus triggering the auto-commit behavior. + // Unfortunately, the auto-commit behavior of IDB can't be switched + // of. There are some proposals to add this functionality in the future. + if (!gotFunResult) { + const msg = + "BUG: transaction closed before transaction function returned"; + console.error(msg); + reject(Error(msg)); + } + resolve(funResult); + }; + tx.onabort = () => { + console.error("aborted transaction"); + reject(AbortTransaction); + }; + const th = new TransactionHandle(tx); + const resP = f(th); + resP.then(result => { + gotFunResult = true; + funResult = result; + }); + }); +} + /** * Definition of an index. */ @@ -98,893 +389,8 @@ export class Index { protected _dummyKey: S | undefined; } -/** - * Stream that can be filtered, reduced or joined - * with indices. - */ -export interface QueryStream { - /** - * Join the current query with values from an index. - * The left side of the join is extracted via a function from the stream's - * result, the right side of the join is the key of the index. - */ - indexJoin( - index: Index, - keyFn: (obj: T) => I, - ): QueryStream>; - /** - * Join the current query with values from an index, and keep values in the - * current stream that don't have a match. The left side of the join is - * extracted via a function from the stream's result, the right side of the - * join is the key of the index. - */ - indexJoinLeft( - index: Index, - keyFn: (obj: T) => I, - ): QueryStream>; - /** - * Join the current query with values from another object store. - * The left side of the join is extracted via a function over the current query, - * the right side of the join is the key of the object store. - */ - keyJoin( - store: Store, - keyFn: (obj: T) => I, - ): QueryStream>; - - /** - * Only keep elements in the result stream for which the predicate returns - * true. - */ - filter(f: (x: T) => boolean): QueryStream; - - /** - * Fold the stream, resulting in a single value. - */ - fold(f: (v: T, acc: S) => S, start: S): Promise; - - /** - * Execute a function for every value of the stream, for the - * side-effects of the function. - */ - forEach(f: (v: T) => void): Promise; - - /** - * Map each element of the stream using a function, resulting in another - * stream of a different type. - */ - map(f: (x: T) => S): QueryStream; - - /** - * Map each element of the stream to a potentially empty array, and collect - * the result in a stream of the flattened arrays. - */ - flatMap(f: (x: T) => S[]): QueryStream; - - /** - * Collect the stream into an array and return a promise for it. - */ - toArray(): Promise; - - /** - * Get the first value of the stream. - */ - first(): QueryValue; - - /** - * Run the query without returning a result. - * Useful for queries with side effects. - */ - run(): Promise; -} - -/** - * Query result that consists of at most one value. - */ -export interface QueryValue { - /** - * Apply a function to a query value. - */ - map(f: (x: T) => S): QueryValue; - /** - * Conditionally execute either of two queries based - * on a property of this query value. - * - * Useful to properly implement complex queries within a transaction (as - * opposed to just computing the conditional and then executing either - * branch). This is necessary since IndexedDB does not allow long-lived - * transactions. - */ - cond( - f: (x: T) => boolean, - onTrue: (r: QueryRoot) => R, - onFalse: (r: QueryRoot) => R, - ): Promise; -} - -abstract class BaseQueryValue implements QueryValue { - constructor(public root: QueryRoot) {} - - map(f: (x: T) => S): QueryValue { - return new MapQueryValue(this, f); - } - - cond( - f: (x: T) => boolean, - onTrue: (r: QueryRoot) => R, - onFalse: (r: QueryRoot) => R, - ): Promise { - return new Promise((resolve, reject) => { - this.subscribeOne((v, tx) => { - if (f(v)) { - onTrue(new QueryRoot(this.root.db)); - } else { - onFalse(new QueryRoot(this.root.db)); - } - }); - resolve(); - }); - } - - abstract subscribeOne(f: SubscribeOneFn): void; -} - -class FirstQueryValue extends BaseQueryValue { - private gotValue = false; - private s: QueryStreamBase; - - constructor(stream: QueryStreamBase) { - super(stream.root); - this.s = stream; - } - - subscribeOne(f: SubscribeOneFn): void { - this.s.subscribe((isDone, value, tx) => { - if (this.gotValue) { - return; - } - if (isDone) { - f(undefined, tx); - } else { - f(value, tx); - } - this.gotValue = true; - }); - } -} - -class MapQueryValue extends BaseQueryValue { - constructor(private v: BaseQueryValue, private mapFn: (x: T) => S) { - super(v.root); - } - - subscribeOne(f: SubscribeOneFn): void { - this.v.subscribeOne((v, tx) => this.mapFn(v)); - } -} /** * Exception that should be thrown by client code to abort a transaction. */ export const AbortTransaction = Symbol("abort_transaction"); - -abstract class QueryStreamBase implements QueryStream { - abstract subscribe( - f: (isDone: boolean, value: any, tx: IDBTransaction) => void, - ): void; - constructor(public root: QueryRoot) {} - - first(): QueryValue { - return new FirstQueryValue(this); - } - - flatMap(f: (x: T) => S[]): QueryStream { - return new QueryStreamFlatMap(this, f); - } - - map(f: (x: T) => S): QueryStream { - return new QueryStreamMap(this, f); - } - - indexJoin( - index: Index, - keyFn: (obj: T) => I, - ): QueryStream> { - this.root.addStoreAccess(index.storeName, false); - return new QueryStreamIndexJoin( - this, - index.storeName, - index.indexName, - keyFn, - ); - } - - indexJoinLeft( - index: Index, - keyFn: (obj: T) => I, - ): QueryStream> { - this.root.addStoreAccess(index.storeName, false); - return new QueryStreamIndexJoinLeft( - this, - index.storeName, - index.indexName, - keyFn, - ); - } - - keyJoin( - store: Store, - keyFn: (obj: T) => I, - ): QueryStream> { - this.root.addStoreAccess(store.name, false); - return new QueryStreamKeyJoin(this, store.name, keyFn); - } - - filter(f: (x: any) => boolean): QueryStream { - return new QueryStreamFilter(this, f); - } - - toArray(): Promise { - const { resolve, promise } = openPromise(); - const values: T[] = []; - - this.subscribe((isDone, value) => { - if (isDone) { - resolve(values); - return; - } - values.push(value); - }); - - return Promise.resolve() - .then(() => this.root.finish()) - .then(() => promise); - } - - fold(f: (x: T, acc: A) => A, init: A): Promise { - const { resolve, promise } = openPromise(); - let acc = init; - - this.subscribe((isDone, value) => { - if (isDone) { - resolve(acc); - return; - } - acc = f(value, acc); - }); - - return Promise.resolve() - .then(() => this.root.finish()) - .then(() => promise); - } - - forEach(f: (x: T) => void): Promise { - const { resolve, promise } = openPromise(); - - this.subscribe((isDone, value) => { - if (isDone) { - resolve(); - return; - } - f(value); - }); - - return Promise.resolve() - .then(() => this.root.finish()) - .then(() => promise); - } - - run(): Promise { - const { resolve, promise } = openPromise(); - - this.subscribe((isDone, value) => { - if (isDone) { - resolve(); - return; - } - }); - - return Promise.resolve() - .then(() => this.root.finish()) - .then(() => promise); - } -} - -type FilterFn = (e: any) => boolean; -type SubscribeFn = (done: boolean, value: any, tx: IDBTransaction) => void; -type SubscribeOneFn = (value: any, tx: IDBTransaction) => void; - -class QueryStreamFilter extends QueryStreamBase { - constructor(public s: QueryStreamBase, public filterFn: FilterFn) { - super(s.root); - } - - subscribe(f: SubscribeFn) { - this.s.subscribe((isDone, value, tx) => { - if (isDone) { - f(true, undefined, tx); - return; - } - if (this.filterFn(value)) { - f(false, value, tx); - } - }); - } -} - -class QueryStreamFlatMap extends QueryStreamBase { - constructor(public s: QueryStreamBase, public flatMapFn: (v: T) => S[]) { - super(s.root); - } - - subscribe(f: SubscribeFn) { - this.s.subscribe((isDone, value, tx) => { - if (isDone) { - f(true, undefined, tx); - return; - } - const values = this.flatMapFn(value); - for (const v in values) { - f(false, v, tx); - } - }); - } -} - -class QueryStreamMap extends QueryStreamBase { - constructor(public s: QueryStreamBase, public mapFn: (v: S) => T) { - super(s.root); - } - - subscribe(f: SubscribeFn) { - this.s.subscribe((isDone, value, tx) => { - if (isDone) { - f(true, undefined, tx); - return; - } - const mappedValue = this.mapFn(value); - f(false, mappedValue, tx); - }); - } -} - -class QueryStreamIndexJoin extends QueryStreamBase> { - constructor( - public s: QueryStreamBase, - public storeName: string, - public indexName: string, - public key: any, - ) { - super(s.root); - } - - subscribe(f: SubscribeFn) { - this.s.subscribe((isDone, value, tx) => { - if (isDone) { - f(true, undefined, tx); - return; - } - const joinKey = this.key(value); - const s = tx.objectStore(this.storeName).index(this.indexName); - const req = s.openCursor(IDBKeyRange.only(joinKey)); - req.onsuccess = () => { - const cursor = req.result; - if (cursor) { - f(false, { left: value, right: cursor.value }, tx); - cursor.continue(); - } - }; - }); - } -} - -class QueryStreamIndexJoinLeft extends QueryStreamBase< - JoinLeftResult -> { - constructor( - public s: QueryStreamBase, - public storeName: string, - public indexName: string, - public key: any, - ) { - super(s.root); - } - - subscribe(f: SubscribeFn) { - this.s.subscribe((isDone, value, tx) => { - if (isDone) { - f(true, undefined, tx); - return; - } - const s = tx.objectStore(this.storeName).index(this.indexName); - const req = s.openCursor(IDBKeyRange.only(this.key(value))); - let gotMatch = false; - req.onsuccess = () => { - const cursor = req.result; - if (cursor) { - gotMatch = true; - f(false, { left: value, right: cursor.value }, tx); - cursor.continue(); - } else { - if (!gotMatch) { - f(false, { left: value }, tx); - } - } - }; - }); - } -} - -class QueryStreamKeyJoin extends QueryStreamBase> { - constructor( - public s: QueryStreamBase, - public storeName: string, - public key: any, - ) { - super(s.root); - } - - subscribe(f: SubscribeFn) { - this.s.subscribe((isDone, value, tx) => { - if (isDone) { - f(true, undefined, tx); - return; - } - const s = tx.objectStore(this.storeName); - const req = s.openCursor(IDBKeyRange.only(this.key(value))); - req.onsuccess = () => { - const cursor = req.result; - if (cursor) { - f(false, { left: value, right: cursor.value }, tx); - cursor.continue(); - } else { - f(true, undefined, tx); - } - }; - }); - } -} - -class IterQueryStream extends QueryStreamBase { - private storeName: string; - private options: any; - private subscribers: SubscribeFn[]; - - constructor(qr: QueryRoot, storeName: string, options: any) { - super(qr); - this.options = options; - this.storeName = storeName; - this.subscribers = []; - - const doIt = (tx: IDBTransaction) => { - const { indexName = void 0, only = void 0 } = this.options; - let s: any; - if (indexName !== void 0) { - s = tx.objectStore(this.storeName).index(this.options.indexName); - } else { - s = tx.objectStore(this.storeName); - } - let kr: IDBKeyRange | undefined; - if (only !== undefined) { - kr = IDBKeyRange.only(this.options.only); - } - const req = s.openCursor(kr); - req.onsuccess = () => { - const cursor: IDBCursorWithValue = req.result; - if (cursor) { - for (const f of this.subscribers) { - f(false, cursor.value, tx); - } - cursor.continue(); - } else { - for (const f of this.subscribers) { - f(true, undefined, tx); - } - } - }; - }; - - this.root.addWork(doIt); - } - - subscribe(f: SubscribeFn) { - this.subscribers.push(f); - } -} - -/** - * Root wrapper around an IndexedDB for queries with a fluent interface. - */ -export class QueryRoot { - private work: Array<(t: IDBTransaction) => void> = []; - private stores: Set = new Set(); - private kickoffPromise: Promise; - - /** - * Some operations is a write operation, - * and we need to do a "readwrite" transaction/ - */ - private hasWrite: boolean; - - private finishScheduled: boolean; - - private finished: boolean = false; - - private keys: { [keyName: string]: IDBValidKey } = {}; - - constructor(public db: IDBDatabase) {} - - /** - * Get a named key that was created during the query. - */ - key(keyName: string): IDBValidKey | undefined { - return this.keys[keyName]; - } - - private checkFinished() { - if (this.finished) { - throw Error("Can't add work to query after it was started"); - } - } - - /** - * Get a stream of all objects in the store. - */ - iter(store: Store): QueryStream { - this.checkFinished(); - this.stores.add(store.name); - this.scheduleFinish(); - return new IterQueryStream(this, store.name, {}); - } - - /** - * Count the number of objects in a store. - */ - count(store: Store): Promise { - this.checkFinished(); - const { resolve, promise } = openPromise(); - - const doCount = (tx: IDBTransaction) => { - const s = tx.objectStore(store.name); - const req = s.count(); - req.onsuccess = () => { - resolve(req.result); - }; - }; - - this.addWork(doCount, store.name, false); - return Promise.resolve() - .then(() => this.finish()) - .then(() => promise); - } - - /** - * Delete all objects in a store that match a predicate. - */ - deleteIf( - store: Store, - predicate: (x: T, n: number) => boolean, - ): QueryRoot { - this.checkFinished(); - const doDeleteIf = (tx: IDBTransaction) => { - const s = tx.objectStore(store.name); - const req = s.openCursor(); - let n = 0; - req.onsuccess = () => { - const cursor: IDBCursorWithValue | null = req.result; - if (cursor) { - if (predicate(cursor.value, n++)) { - cursor.delete(); - } - cursor.continue(); - } - }; - }; - this.addWork(doDeleteIf, store.name, true); - return this; - } - - iterIndex( - index: Index, - only?: S, - ): QueryStream { - this.checkFinished(); - this.stores.add(index.storeName); - this.scheduleFinish(); - return new IterQueryStream(this, index.storeName, { - indexName: index.indexName, - only, - }); - } - - /** - * Put an object into the given object store. - * Overrides if an existing object with the same key exists - * in the store. - */ - put(store: Store, val: T, keyName?: string): QueryRoot { - this.checkFinished(); - const doPut = (tx: IDBTransaction) => { - const req = tx.objectStore(store.name).put(val); - if (keyName) { - req.onsuccess = () => { - this.keys[keyName] = req.result; - }; - } - }; - this.scheduleFinish(); - this.addWork(doPut, store.name, true); - return this; - } - - /** - * Put an object into a store or return an existing record. - */ - putOrGetExisting(store: Store, val: T, key: IDBValidKey): Promise { - this.checkFinished(); - const { resolve, promise } = openPromise(); - const doPutOrGet = (tx: IDBTransaction) => { - const objstore = tx.objectStore(store.name); - const req = objstore.get(key); - req.onsuccess = () => { - if (req.result !== undefined) { - resolve(req.result); - } else { - const req2 = objstore.add(val); - req2.onsuccess = () => { - resolve(val); - }; - } - }; - }; - this.scheduleFinish(); - this.addWork(doPutOrGet, store.name, true); - return promise; - } - - putWithResult(store: Store, val: T): Promise { - this.checkFinished(); - const { resolve, promise } = openPromise(); - const doPutWithResult = (tx: IDBTransaction) => { - const req = tx.objectStore(store.name).put(val); - req.onsuccess = () => { - resolve(req.result); - }; - this.scheduleFinish(); - }; - this.addWork(doPutWithResult, store.name, true); - return Promise.resolve() - .then(() => this.finish()) - .then(() => promise); - } - - /** - * Update objects inside a transaction. - * - * If the mutation function throws AbortTransaction, the whole transaction will be aborted. - * If the mutation function returns undefined or null, no modification will be made. - */ - mutate(store: Store, key: any, f: (v: T) => T | undefined): QueryRoot { - this.checkFinished(); - const doPut = (tx: IDBTransaction) => { - const req = tx.objectStore(store.name).openCursor(IDBKeyRange.only(key)); - req.onsuccess = () => { - const cursor = req.result; - if (cursor) { - const value = cursor.value; - let modifiedValue: T | undefined; - try { - modifiedValue = f(value); - } catch (e) { - if (e === AbortTransaction) { - tx.abort(); - return; - } - throw e; - } - if (modifiedValue !== undefined && modifiedValue !== null) { - cursor.update(modifiedValue); - } - cursor.continue(); - } - }; - }; - this.scheduleFinish(); - this.addWork(doPut, store.name, true); - return this; - } - - /** - * Add all object from an iterable to the given object store. - */ - putAll(store: Store, iterable: T[]): QueryRoot { - this.checkFinished(); - const doPutAll = (tx: IDBTransaction) => { - for (const obj of iterable) { - tx.objectStore(store.name).put(obj); - } - }; - this.scheduleFinish(); - this.addWork(doPutAll, store.name, true); - return this; - } - - /** - * Add an object to the given object store. - * Fails if the object's key is already present - * in the object store. - */ - add(store: Store, val: T): QueryRoot { - this.checkFinished(); - const doAdd = (tx: IDBTransaction) => { - tx.objectStore(store.name).add(val); - }; - this.scheduleFinish(); - this.addWork(doAdd, store.name, true); - return this; - } - - /** - * Get one object from a store by its key. - */ - get(store: Store, key: any): Promise { - this.checkFinished(); - if (key === void 0) { - throw Error("key must not be undefined"); - } - - const { resolve, promise } = openPromise(); - - const doGet = (tx: IDBTransaction) => { - const req = tx.objectStore(store.name).get(key); - req.onsuccess = () => { - resolve(req.result); - }; - }; - - this.addWork(doGet, store.name, false); - return Promise.resolve() - .then(() => this.finish()) - .then(() => promise); - } - - /** - * Get get objects from a store by their keys. - * If no object for a key exists, the resulting position in the array - * contains 'undefined'. - */ - getMany(store: Store, keys: any[]): Promise { - this.checkFinished(); - - const { resolve, promise } = openPromise(); - const results: T[] = []; - - const doGetMany = (tx: IDBTransaction) => { - for (const key of keys) { - if (key === void 0) { - throw Error("key must not be undefined"); - } - const req = tx.objectStore(store.name).get(key); - req.onsuccess = () => { - results.push(req.result); - if (results.length === keys.length) { - resolve(results); - } - }; - } - }; - - this.addWork(doGetMany, store.name, false); - return Promise.resolve() - .then(() => this.finish()) - .then(() => promise); - } - - /** - * Get one object from a store by its key. - */ - getIndexed( - index: Index, - key: I, - ): Promise { - this.checkFinished(); - if (key === void 0) { - throw Error("key must not be undefined"); - } - - const { resolve, promise } = openPromise(); - - const doGetIndexed = (tx: IDBTransaction) => { - const req = tx - .objectStore(index.storeName) - .index(index.indexName) - .get(key); - req.onsuccess = () => { - resolve(req.result); - }; - }; - - this.addWork(doGetIndexed, index.storeName, false); - return Promise.resolve() - .then(() => this.finish()) - .then(() => promise); - } - - private scheduleFinish() { - if (!this.finishScheduled) { - Promise.resolve().then(() => this.finish()); - this.finishScheduled = true; - } - } - - /** - * Finish the query, and start the query in the first place if necessary. - */ - finish(): Promise { - if (this.kickoffPromise) { - return this.kickoffPromise; - } - this.kickoffPromise = new Promise((resolve, reject) => { - // At this point, we can't add any more work - this.finished = true; - if (this.work.length === 0) { - resolve(); - return; - } - const mode = this.hasWrite ? "readwrite" : "readonly"; - const tx = this.db.transaction(Array.from(this.stores), mode); - tx.oncomplete = () => { - resolve(); - }; - tx.onabort = () => { - console.warn( - `aborted ${mode} transaction on stores [${[...this.stores]}]`, - ); - reject(Error("transaction aborted")); - }; - tx.onerror = e => { - console.warn(`error in transaction`, (e.target as any).error); - }; - for (const w of this.work) { - w(tx); - } - }); - return this.kickoffPromise; - } - - /** - * Delete an object by from the given object store. - */ - delete(store: Store, key: any): QueryRoot { - this.checkFinished(); - const doDelete = (tx: IDBTransaction) => { - tx.objectStore(store.name).delete(key); - }; - this.scheduleFinish(); - this.addWork(doDelete, store.name, true); - return this; - } - - /** - * Low-level function to add a task to the internal work queue. - */ - addWork( - workFn: (t: IDBTransaction) => void, - storeName?: string, - isWrite?: boolean, - ) { - this.work.push(workFn); - if (storeName) { - this.addStoreAccess(storeName, isWrite); - } - } - - addStoreAccess(storeName: string, isWrite?: boolean) { - if (storeName) { - this.stores.add(storeName); - } - if (isWrite) { - this.hasWrite = true; - } - } -} diff --git a/src/wallet.ts b/src/wallet.ts index f5219c459..32b0833cd 100644 --- a/src/wallet.ts +++ b/src/wallet.ts @@ -29,14 +29,19 @@ import { canonicalizeBaseUrl, getTalerStampSec, strcmp, + extractTalerStamp, } from "./helpers"; import { HttpRequestLibrary, RequestException } from "./http"; import * as LibtoolVersion from "./libtoolVersion"; import { AbortTransaction, - JoinLeftResult, - JoinResult, - QueryRoot, + oneShotPut, + oneShotGet, + runWithWriteTransaction, + oneShotIter, + oneShotIterIndex, + oneShotGetIndexed, + oneShotMutate, } from "./query"; import { TimerGroup } from "./timer"; @@ -63,6 +68,8 @@ import { TipRecord, WireFee, WithdrawalRecord, + ExchangeDetails, + ExchangeUpdateStatus, } from "./dbTypes"; import { Auditor, @@ -110,6 +117,8 @@ import { PendingOperationInfo, PendingOperationsResponse, HistoryQuery, + getTimestampNow, + OperationError, } from "./walletTypes"; import { openPromise } from "./promiseUtils"; import { @@ -338,6 +347,18 @@ interface CoinsForPaymentArgs { wireMethod: string; } +/** + * This error is thrown when an + */ +class OperationFailedAndReportedError extends Error { + constructor(public reason: Error) { + super("Reported failed operation: " + reason.message); + + // Set the prototype explicitly. + Object.setPrototypeOf(this, OperationFailedAndReportedError.prototype); + } +} + /** * The platform-independent wallet implementation. */ @@ -372,10 +393,6 @@ export class Wallet { */ private runningOperations: Set = new Set(); - q(): QueryRoot { - return new QueryRoot(this.db); - } - constructor( db: IDBDatabase, http: HttpRequestLibrary, @@ -389,31 +406,49 @@ export class Wallet { this.notifier = notifier; this.cryptoApi = new CryptoApi(cryptoWorkerFactory); this.timerGroup = new TimerGroup(); + } - const init = async () => { - await this.fillDefaults().catch(e => console.log(e)); + public async processPending(): Promise { + + const exchangeBaseUrlList = await oneShotIter(this.db, Stores.exchanges).map((x) => x.baseUrl); + + for (let exchangeBaseUrl of exchangeBaseUrlList) { + await this.updateExchangeFromUrl(exchangeBaseUrl); + } + } + + /** + * Start processing pending operations asynchronously. + */ + public start() { + const work = async () => { await this.collectGarbage().catch(e => console.log(e)); this.updateExchanges(); this.resumePendingFromDb(); this.timerGroup.every(1000 * 60 * 15, () => this.updateExchanges()); }; - - init(); + work(); } - private async fillDefaults() { - const onTrue = (r: QueryRoot) => {}; - const onFalse = (r: QueryRoot) => { - Wallet.enableTracing && console.log("applying defaults"); - r.put(Stores.config, { key: "currencyDefaultsApplied", value: true }) - .putAll(Stores.currencies, builtinCurrencies) - .finish(); - }; - await this.q() - .iter(Stores.config) - .filter(x => x.key === "currencyDefaultsApplied") - .first() - .cond(x => x && x.value, onTrue, onFalse); + /** + * Insert the hard-coded defaults for exchanges, coins and + * auditors into the database, unless these defaults have + * already been applied. + */ + async fillDefaults() { + await runWithWriteTransaction(this.db, [Stores.config, Stores.currencies], async (tx) => { + let applied = false; + await tx.iter(Stores.config).forEach((x) => { + if (x.key == "currencyDefaultsApplied" && x.value == true) { + applied = true; + } + }); + if (!applied) { + for (let c of builtinCurrencies) { + await tx.put(Stores.currencies, c); + } + } + }); } private startOperation(operationId: string) { @@ -429,12 +464,9 @@ export class Wallet { } async updateExchanges(): Promise { - const exchangesUrls = await this.q() - .iter(Stores.exchanges) - .map(e => e.baseUrl) - .toArray(); + const exchangeUrls = await oneShotIter(this.db, Stores.exchanges).map((e) => e.baseUrl); - for (const url of exchangesUrls) { + for (const url of exchangeUrls) { this.updateExchangeFromUrl(url).catch(e => { console.error("updating exchange failed", e); }); @@ -448,69 +480,46 @@ export class Wallet { private resumePendingFromDb(): void { Wallet.enableTracing && console.log("resuming pending operations from db"); - this.q() - .iter(Stores.reserves) - .forEach(reserve => { + oneShotIter(this.db, Stores.reserves).forEach(reserve => { Wallet.enableTracing && console.log("resuming reserve", reserve.reserve_pub); this.processReserve(reserve.reserve_pub); - }); + }); - this.q() - .iter(Stores.precoins) - .forEach(preCoin => { - Wallet.enableTracing && console.log("resuming precoin"); - this.processPreCoin(preCoin.coinPub); - }); + oneShotIter(this.db, Stores.precoins).forEach(preCoin => { + Wallet.enableTracing && console.log("resuming precoin"); + this.processPreCoin(preCoin.coinPub); + }); - this.q() - .iter(Stores.refresh) - .forEach((r: RefreshSessionRecord) => { - this.continueRefreshSession(r); - }); + oneShotIter(this.db, Stores.refresh).forEach((r: RefreshSessionRecord) => { + this.continueRefreshSession(r); + }); - this.q() - .iter(Stores.coinsReturns) - .forEach((r: CoinsReturnRecord) => { - this.depositReturnedCoins(r); - }); - - // FIXME: optimize via index - this.q() - .iter(Stores.coins) - .forEach((c: CoinRecord) => { - if (c.status === CoinStatus.Dirty) { - Wallet.enableTracing && - console.log("resuming pending refresh for coin", c); - this.refresh(c.coinPub); - } - }); + oneShotIter(this.db, Stores.coinsReturns).forEach((r: CoinsReturnRecord) => { + this.depositReturnedCoins(r); + }); } private async getCoinsForReturn( exchangeBaseUrl: string, amount: AmountJson, ): Promise { - const exchange = await this.q().get(Stores.exchanges, exchangeBaseUrl); + const exchange = await oneShotGet(this.db, Stores.exchanges, exchangeBaseUrl); if (!exchange) { throw Error(`Exchange ${exchangeBaseUrl} not known to the wallet`); } - const coins: CoinRecord[] = await this.q() - .iterIndex(Stores.coins.exchangeBaseUrlIndex, exchange.baseUrl) - .toArray(); + const coins: CoinRecord[] = await oneShotIterIndex(this.db, Stores.coins.exchangeBaseUrlIndex, exchange.baseUrl).toArray(); if (!coins || !coins.length) { return []; } - const denoms = await this.q() - .iterIndex(Stores.denominations.exchangeBaseUrlIndex, exchange.baseUrl) - .toArray(); + const denoms = await oneShotIterIndex(this.db, Stores.denominations.exchangeBaseUrlIndex, exchange.baseUrl).toArray(); // Denomination of the first coin, we assume that all other // coins have the same currency - const firstDenom = await this.q().get(Stores.denominations, [ + const firstDenom = await oneShotGet(this.db, Stores.denominations, [ exchange.baseUrl, coins[0].denomPub, ]); @@ -521,7 +530,7 @@ export class Wallet { const cds: CoinWithDenom[] = []; for (const coin of coins) { - const denom = await this.q().get(Stores.denominations, [ + const denom = await oneShotGet(this.db, Stores.denominations, [ exchange.baseUrl, coin.denomPub, ]); @@ -572,16 +581,22 @@ export class Wallet { let remainingAmount = paymentAmount; - const exchanges = await this.q() - .iter(Stores.exchanges) - .toArray(); + const exchanges = await oneShotIter(this.db, Stores.exchanges).toArray(); for (const exchange of exchanges) { let isOkay: boolean = false; + const exchangeDetails = exchange.details; + if (!exchangeDetails) { + continue; + } + const exchangeFees = exchange.wireInfo; + if (!exchangeFees) { + continue; + } // is the exchange explicitly allowed? for (const allowedExchange of allowedExchanges) { - if (allowedExchange.master_pub === exchange.masterPublicKey) { + if (allowedExchange.master_pub === exchangeDetails.masterPublicKey) { isOkay = true; break; } @@ -590,7 +605,7 @@ export class Wallet { // is the exchange allowed because of one of its auditors? if (!isOkay) { for (const allowedAuditor of allowedAuditors) { - for (const auditor of exchange.auditors) { + for (const auditor of exchangeDetails.auditors) { if (auditor.auditor_pub === allowedAuditor.auditor_pub) { isOkay = true; break; @@ -606,20 +621,17 @@ export class Wallet { continue; } - const coins: CoinRecord[] = await this.q() - .iterIndex(Stores.coins.exchangeBaseUrlIndex, exchange.baseUrl) - .toArray(); + const coins = await oneShotIterIndex(this.db, Stores.coins.exchangeBaseUrlIndex, exchange.baseUrl).toArray(); + + const denoms = await oneShotIterIndex(this.db, Stores.denominations.exchangeBaseUrlIndex, exchange.baseUrl).toArray(); - const denoms = await this.q() - .iterIndex(Stores.denominations.exchangeBaseUrlIndex, exchange.baseUrl) - .toArray(); if (!coins || coins.length === 0) { continue; } // Denomination of the first coin, we assume that all other // coins have the same currency - const firstDenom = await this.q().get(Stores.denominations, [ + const firstDenom = await oneShotGet(this.db, Stores.denominations, [ exchange.baseUrl, coins[0].denomPub, ]); @@ -629,7 +641,7 @@ export class Wallet { const currency = firstDenom.value.currency; const cds: CoinWithDenom[] = []; for (const coin of coins) { - const denom = await this.q().get(Stores.denominations, [ + const denom = await oneShotGet(this.db, Stores.denominations, [ exchange.baseUrl, coin.denomPub, ]); @@ -651,18 +663,9 @@ export class Wallet { cds.push({ coin, denom }); } - const fees = await this.q().get( - Stores.exchangeWireFees, - exchange.baseUrl, - ); - if (!fees) { - console.error("no fees found for exchange", exchange); - continue; - } - let totalFees = Amounts.getZero(currency); let wireFee: AmountJson | undefined; - for (const fee of fees.feesForType[wireMethod] || []) { + for (const fee of exchangeFees.feesForType[wireMethod] || []) { if (fee.startStamp <= wireFeeTime && fee.endStamp >= wireFeeTime) { wireFee = fee.wireFee; break; @@ -723,10 +726,13 @@ export class Wallet { timestamp_refund: 0, }; - await this.q() - .put(Stores.purchases, t) - .putAll(Stores.coins, payCoinInfo.updatedCoins) - .finish(); + await runWithWriteTransaction(this.db, [Stores.coins, Stores.purchases], async (tx) => { + await tx.put(Stores.purchases, t); + for (let c of payCoinInfo.updatedCoins) { + await tx.put(Stores.coins, c); + } + }); + this.badge.showNotification(); this.notifier.notify(); return t; @@ -773,7 +779,8 @@ export class Wallet { console.log("proposal", proposal); - const differentPurchase = await this.q().getIndexed( + const differentPurchase = await oneShotGetIndexed( + this.db, Stores.purchases.fulfillmentUrlIndex, proposal.contractTerms.fulfillment_url, ); @@ -805,10 +812,7 @@ export class Wallet { } // First check if we already payed for it. - const purchase = await this.q().get( - Stores.purchases, - proposal.contractTermsHash, - ); + const purchase = await oneShotGet(this.db, Stores.purchases, proposal.contractTermsHash); if (!purchase) { const paymentAmount = Amounts.parseOrThrow(proposal.contractTerms.amount); @@ -890,10 +894,7 @@ export class Wallet { * downloaded in the context of a session ID. */ async downloadProposal(url: string, sessionId?: string): Promise { - const oldProposal = await this.q().getIndexed( - Stores.proposals.urlIndex, - url, - ); + const oldProposal = await oneShotGetIndexed(this.db, Stores.proposals.urlIndex, url); if (oldProposal) { return oldProposal.id!; } @@ -924,7 +925,7 @@ export class Wallet { downloadSessionId: sessionId, }; - const id = await this.q().putWithResult(Stores.proposals, proposalRecord); + const id = await oneShotPut(this.db, Stores.proposals, proposalRecord); this.notifier.notify(); if (typeof id !== "number") { throw Error("db schema wrong"); @@ -934,19 +935,15 @@ export class Wallet { async refundFailedPay(proposalId: number) { console.log(`refunding failed payment with proposal id ${proposalId}`); - const proposal: ProposalDownloadRecord | undefined = await this.q().get( - Stores.proposals, - proposalId, - ); - + const proposal = await oneShotGet(this.db, Stores.proposals, proposalId); if (!proposal) { throw Error(`proposal with id ${proposalId} not found`); } - const purchase = await this.q().get( + const purchase = await oneShotGet(this.db, Stores.purchases, - proposal.contractTermsHash, - ); + proposal.contractTermsHash); + if (!purchase) { throw Error("purchase not found for proposal"); } @@ -960,7 +957,7 @@ export class Wallet { contractTermsHash: string, sessionId: string | undefined, ): Promise { - const purchase = await this.q().get(Stores.purchases, contractTermsHash); + const purchase = await oneShotGet(this.db, Stores.purchases, contractTermsHash); if (!purchase) { throw Error("Purchase not found: " + contractTermsHash); } @@ -998,7 +995,7 @@ export class Wallet { purchase.finished = true; const modifiedCoins: CoinRecord[] = []; for (const pc of purchase.payReq.coins) { - const c = await this.q().get(Stores.coins, pc.coin_pub); + const c = await oneShotGet(this.db, Stores.coins, pc.coin_pub); if (!c) { console.error("coin not found"); throw Error("coin used in payment not found"); @@ -1007,10 +1004,13 @@ export class Wallet { modifiedCoins.push(c); } - await this.q() - .putAll(Stores.coins, modifiedCoins) - .put(Stores.purchases, purchase) - .finish(); + await runWithWriteTransaction(this.db, [Stores.coins, Stores.purchases], async (tx) => { + for (let c of modifiedCoins) { + tx.put(Stores.coins, c); + } + tx.put(Stores.purchases, purchase); + }); + for (const c of purchase.payReq.coins) { this.refresh(c.coin_pub); } @@ -1031,9 +1031,7 @@ export class Wallet { */ async refreshDirtyCoins(): Promise<{ numRefreshed: number }> { let n = 0; - const coins = await this.q() - .iter(Stores.coins) - .toArray(); + const coins = await oneShotIter(this.db, Stores.coins).toArray(); for (let coin of coins) { if (coin.status == CoinStatus.Dirty) { try { @@ -1059,10 +1057,7 @@ export class Wallet { console.log( `executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`, ); - const proposal: ProposalDownloadRecord | undefined = await this.q().get( - Stores.proposals, - proposalId, - ); + const proposal = await oneShotGet(this.db, Stores.proposals, proposalId); if (!proposal) { throw Error(`proposal with id ${proposalId} not found`); @@ -1070,10 +1065,9 @@ export class Wallet { const sessionId = sessionIdOverride || proposal.downloadSessionId; - let purchase = await this.q().get( + let purchase = await oneShotGet(this.db, Stores.purchases, - proposal.contractTermsHash, - ); + proposal.contractTermsHash,); if (purchase) { return this.submitPay(purchase.contractTermsHash, sessionId); @@ -1145,7 +1139,13 @@ export class Wallet { return; } const coinKeys = sp.payCoinInfo.updatedCoins.map(x => x.coinPub); - const coins = await this.q().getMany(Stores.coins, coinKeys); + const coins: CoinRecord[] = []; + for (let coinKey of coinKeys) { + const cc = await oneShotGet(this.db, Stores.coins, coinKey); + if (cc) { + coins.push(cc); + } + } for (let i = 0; i < coins.length; i++) { const specCoin = sp.payCoinInfo.originalCoins[i]; const currentCoin = coins[i]; @@ -1164,13 +1164,12 @@ export class Wallet { } /** - * Send reserve details + * Send reserve details */ private async sendReserveInfoToBank(reservePub: string) { - const reserve = await this.q().get( + const reserve = await oneShotGet(this.db, Stores.reserves, - reservePub, - ); + reservePub); if (!reserve) { throw Error("reserve not in db"); } @@ -1191,7 +1190,7 @@ export class Wallet { } if (status.transfer_done) { - await this.q().mutate(Stores.reserves, reservePub, r => { + await oneShotMutate(this.db, Stores.reserves, reservePub, (r) => { r.timestamp_confirmed = now; return r; }); @@ -1207,7 +1206,7 @@ export class Wallet { console.log("bank error response", e); throw e; } - await this.q().mutate(Stores.reserves, reservePub, r => { + await oneShotMutate(this.db, Stores.reserves, reservePub, (r) => { r.timestamp_reserve_info_posted = now; return r; }); @@ -1238,10 +1237,7 @@ export class Wallet { // Sometimes though, we want to try again faster. let maxTimeout = 3000 * 60; try { - const reserve = await this.q().get( - Stores.reserves, - reservePub, - ); + const reserve = await oneShotGet(this.db, Stores.reserves, reservePub); if (!reserve) { isHardError = true; throw Error("reserve not in db"); @@ -1302,7 +1298,7 @@ export class Wallet { const op = openPromise(); const processPreCoinInternal = async (retryDelayMs: number = 200) => { - const preCoin = await this.q().get(Stores.precoins, preCoinPub); + const preCoin = await oneShotGet(this.db, Stores.precoins, preCoinPub); if (!preCoin) { console.log("processPreCoin: preCoinPub not found"); return; @@ -1325,15 +1321,14 @@ export class Wallet { this.processPreCoinConcurrent++; try { - const exchange = await this.q().get( + const exchange = await oneShotGet(this.db, Stores.exchanges, - preCoin.exchangeBaseUrl, - ); + preCoin.exchangeBaseUrl,); if (!exchange) { console.error("db inconsistent: exchange for precoin not found"); return; } - const denom = await this.q().get(Stores.denominations, [ + const denom = await oneShotGet(this.db, Stores.denominations, [ preCoin.exchangeBaseUrl, preCoin.denomPub, ]); @@ -1358,11 +1353,11 @@ export class Wallet { return r; }; - await this.q() - .mutate(Stores.reserves, preCoin.reservePub, mutateReserve) - .delete(Stores.precoins, coin.coinPub) - .add(Stores.coins, coin) - .finish(); + await runWithWriteTransaction(this.db, [Stores.reserves, Stores.precoins, Stores.coins], async (tx) => { + await tx.mutate(Stores.reserves, preCoin.reservePub, mutateReserve); + await tx.delete(Stores.precoins, coin.coinPub); + await tx.add(Stores.coins, coin); + }); this.badge.showNotification(); @@ -1402,20 +1397,6 @@ export class Wallet { } } - /** - * Update the timestamp of when an exchange was used. - */ - async updateExchangeUsedTime(exchangeBaseUrl: string): Promise { - const now = new Date().getTime(); - const update = (r: ExchangeRecord) => { - r.lastUsedTime = now; - return r; - }; - await this.q() - .mutate(Stores.exchanges, exchangeBaseUrl, update) - .finish(); - } - /** * Create a reserve, but do not flag it as confirmed yet. * @@ -1451,38 +1432,38 @@ export class Wallet { const rec = { paytoUri: senderWire, }; - await this.q() - .put(Stores.senderWires, rec) - .finish(); + await oneShotPut(this.db, Stores.senderWires, rec); } - await this.updateExchangeUsedTime(req.exchange); const exchangeInfo = await this.updateExchangeFromUrl(req.exchange); + const exchangeDetails = exchangeInfo.details; + if (!exchangeDetails) { + throw Error("exchange not updated"); + } const { isAudited, isTrusted } = await this.getExchangeTrust(exchangeInfo); - let currencyRecord = await this.q().get( - Stores.currencies, - exchangeInfo.currency, - ); + let currencyRecord = await oneShotGet(this.db, Stores.currencies, exchangeDetails.currency); if (!currencyRecord) { currencyRecord = { auditors: [], exchanges: [], fractionalDigits: 2, - name: exchangeInfo.currency, + name: exchangeDetails.currency, }; } if (!isAudited && !isTrusted) { currencyRecord.exchanges.push({ baseUrl: req.exchange, - exchangePub: exchangeInfo.masterPublicKey, + exchangePub: exchangeDetails.masterPublicKey, }); } - await this.q() - .put(Stores.currencies, currencyRecord) - .put(Stores.reserves, reserveRecord) - .finish(); + const cr: CurrencyRecord = currencyRecord; + + runWithWriteTransaction(this.db, [Stores.currencies, Stores.reserves], async (tx) => { + await tx.put(Stores.currencies, cr); + await tx.put(Stores.reserves, reserveRecord); + }); if (req.bankWithdrawStatusUrl) { this.processReserve(keypair.pub); @@ -1506,17 +1487,13 @@ export class Wallet { */ async confirmReserve(req: ConfirmReserveRequest): Promise { const now = new Date().getTime(); - const reserve: ReserveRecord | undefined = await this.q().get< - ReserveRecord - >(Stores.reserves, req.reservePub); + const reserve = await oneShotGet(this.db, Stores.reserves, req.reservePub); if (!reserve) { console.error("Unable to confirm reserve, not found in DB"); return; } reserve.timestamp_confirmed = now; - await this.q() - .put(Stores.reserves, reserve) - .finish(); + await oneShotPut(this.db, Stores.reserves, reserve); this.notifier.notify(); this.processReserve(reserve.reserve_pub); @@ -1589,22 +1566,33 @@ export class Wallet { reservePub: reserve.reserve_pub, withdrawalAmount: Amounts.toString(withdrawAmount), startTimestamp: stampMsNow, - } + }; - const preCoinRecords: PreCoinRecord[] = await Promise.all(denomsForWithdraw.map(async denom => { - return await this.cryptoApi.createPreCoin(denom, reserve); - })); + const preCoinRecords: PreCoinRecord[] = await Promise.all( + denomsForWithdraw.map(async denom => { + return await this.cryptoApi.createPreCoin(denom, reserve); + }), + ); - const totalCoinValue = Amounts.sum(denomsForWithdraw.map(x => x.value)).amount - const totalCoinWithdrawFee = Amounts.sum(denomsForWithdraw.map(x => x.feeWithdraw)).amount - const totalWithdrawAmount = Amounts.add(totalCoinValue, totalCoinWithdrawFee).amount + const totalCoinValue = Amounts.sum(denomsForWithdraw.map(x => x.value)) + .amount; + const totalCoinWithdrawFee = Amounts.sum( + denomsForWithdraw.map(x => x.feeWithdraw), + ).amount; + const totalWithdrawAmount = Amounts.add( + totalCoinValue, + totalCoinWithdrawFee, + ).amount; function mutateReserve(r: ReserveRecord): ReserveRecord { const currentAmount = r.current_amount; if (!currentAmount) { throw Error("can't withdraw when amount is unknown"); } - r.precoin_amount = Amounts.add(r.precoin_amount, totalWithdrawAmount).amount; + r.precoin_amount = Amounts.add( + r.precoin_amount, + totalWithdrawAmount, + ).amount; const result = Amounts.sub(currentAmount, totalWithdrawAmount); if (result.saturated) { console.error("can't create precoins, saturated"); @@ -1623,11 +1611,13 @@ export class Wallet { // This will fail and throw an exception if the remaining amount in the // reserve is too low to create a pre-coin. try { - await this.q() - .putAll(Stores.precoins, preCoinRecords) - .put(Stores.withdrawals, withdrawalRecord) - .mutate(Stores.reserves, reserve.reserve_pub, mutateReserve) - .finish(); + await runWithWriteTransaction(this.db, [Stores.precoins, Stores.withdrawals, Stores.reserves], async (tx) => { + for (let pcr of preCoinRecords) { + await tx.put(Stores.precoins, pcr); + } + await tx.mutate(Stores.reserves, reserve.reserve_pub, mutateReserve); + await tx.put(Stores.withdrawals, withdrawalRecord); + }); } catch (e) { return; } @@ -1642,10 +1632,7 @@ export class Wallet { * by quering the reserve's exchange. */ private async updateReserve(reservePub: string): Promise { - const reserve = await this.q().get( - Stores.reserves, - reservePub, - ); + const reserve = await oneShotGet(this.db, Stores.reserves, reservePub); if (!reserve) { throw Error("reserve not in db"); } @@ -1669,44 +1656,16 @@ export class Wallet { throw Error(); } reserve.current_amount = Amounts.parseOrThrow(reserveInfo.balance); - await this.q() - .put(Stores.reserves, reserve) - .finish(); + await oneShotPut(this.db, Stores.reserves, reserve); this.notifier.notify(); return reserve; } - /** - * Get the wire information for the exchange with the given base URL. - */ - async getWireInfo(exchangeBaseUrl: string): Promise { - exchangeBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl); - const reqUrl = new URI("wire") - .absoluteTo(exchangeBaseUrl) - .addQuery("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION); - const resp = await this.http.get(reqUrl.href()); - - if (resp.status !== 200) { - throw Error("/wire request failed"); - } - - const wiJson = resp.responseJson; - if (!wiJson) { - throw Error("/wire response malformed"); - } - - return ExchangeWireJson.checked(wiJson); - } - - async getPossibleDenoms(exchangeBaseUrl: string) { - return this.q() - .iterIndex(Stores.denominations.exchangeBaseUrlIndex, exchangeBaseUrl) - .filter( - d => - d.status === DenominationStatus.Unverified || - d.status === DenominationStatus.VerifiedGood, - ) - .toArray(); + async getPossibleDenoms(exchangeBaseUrl: string): Promise { + return await oneShotIterIndex(this.db, Stores.denominations.exchangeBaseUrlIndex, exchangeBaseUrl).filter((d) => { + return d.status === DenominationStatus.Unverified || + d.status === DenominationStatus.VerifiedGood; + }); } /** @@ -1718,19 +1677,17 @@ export class Wallet { async getVerifiedSmallestWithdrawAmount( exchangeBaseUrl: string, ): Promise { - const exchange = await this.q().get(Stores.exchanges, exchangeBaseUrl); + const exchange = await oneShotGet(this.db, Stores.exchanges, exchangeBaseUrl); if (!exchange) { throw Error(`exchange ${exchangeBaseUrl} not found`); } + const exchangeDetails = exchange.details; + if (!exchangeDetails) { + throw Error(`exchange ${exchangeBaseUrl} details not available`); + } + + const possibleDenoms = await this.getPossibleDenoms(exchange.baseUrl); - const possibleDenoms = await this.q() - .iterIndex(Stores.denominations.exchangeBaseUrlIndex, exchange.baseUrl) - .filter( - d => - d.status === DenominationStatus.Unverified || - d.status === DenominationStatus.VerifiedGood, - ) - .toArray(); possibleDenoms.sort((d1, d2) => { const a1 = Amounts.add(d1.feeWithdraw, d1.value).amount; const a2 = Amounts.add(d2.feeWithdraw, d2.value).amount; @@ -1743,21 +1700,19 @@ export class Wallet { } const valid = await this.cryptoApi.isValidDenom( denom, - exchange.masterPublicKey, + exchangeDetails.masterPublicKey, ); if (!valid) { denom.status = DenominationStatus.VerifiedBad; } else { denom.status = DenominationStatus.VerifiedGood; } - await this.q() - .put(Stores.denominations, denom) - .finish(); + await oneShotPut(this.db, Stores.denominations, denom); if (valid) { return Amounts.add(denom.feeWithdraw, denom.value).amount; } } - return Amounts.getZero(exchange.currency); + return Amounts.getZero(exchangeDetails.currency); } /** @@ -1771,19 +1726,16 @@ export class Wallet { exchangeBaseUrl: string, amount: AmountJson, ): Promise { - const exchange = await this.q().get(Stores.exchanges, exchangeBaseUrl); + const exchange = await oneShotGet(this.db, Stores.exchanges, exchangeBaseUrl); if (!exchange) { throw Error(`exchange ${exchangeBaseUrl} not found`); } + const exchangeDetails = exchange.details; + if (!exchangeDetails) { + throw Error(`exchange ${exchangeBaseUrl} details not available`); + } - const possibleDenoms = await this.q() - .iterIndex(Stores.denominations.exchangeBaseUrlIndex, exchange.baseUrl) - .filter( - d => - d.status === DenominationStatus.Unverified || - d.status === DenominationStatus.VerifiedGood, - ) - .toArray(); + const possibleDenoms = await this.getPossibleDenoms(exchange.baseUrl); let allValid = false; @@ -1797,7 +1749,7 @@ export class Wallet { if (denom.status === DenominationStatus.Unverified) { const valid = await this.cryptoApi.isValidDenom( denom, - exchange.masterPublicKey, + exchangeDetails.masterPublicKey, ); if (!valid) { denom.status = DenominationStatus.VerifiedBad; @@ -1806,9 +1758,7 @@ export class Wallet { denom.status = DenominationStatus.VerifiedGood; nextPossibleDenoms.push(denom); } - await this.q() - .put(Stores.denominations, denom) - .finish(); + await oneShotPut(this.db, Stores.denominations, denom); } else { nextPossibleDenoms.push(denom); } @@ -1826,19 +1776,22 @@ export class Wallet { ): Promise<{ isTrusted: boolean; isAudited: boolean }> { let isTrusted = false; let isAudited = false; - const currencyRecord = await this.q().get( + const exchangeDetails = exchangeInfo.details; + if (!exchangeDetails) { + throw Error(`exchange ${exchangeInfo.baseUrl} details not available`); + } + const currencyRecord = await oneShotGet(this.db, Stores.currencies, - exchangeInfo.currency, - ); + exchangeDetails.currency); if (currencyRecord) { for (const trustedExchange of currencyRecord.exchanges) { - if (trustedExchange.exchangePub === exchangeInfo.masterPublicKey) { + if (trustedExchange.exchangePub === exchangeDetails.masterPublicKey) { isTrusted = true; break; } } for (const trustedAuditor of currencyRecord.auditors) { - for (const exchangeAuditor of exchangeInfo.auditors) { + for (const exchangeAuditor of exchangeDetails.auditors) { if (trustedAuditor.auditorPub === exchangeAuditor.auditor_pub) { isAudited = true; break; @@ -1872,6 +1825,16 @@ export class Wallet { amount: AmountJson, ): Promise { const exchangeInfo = await this.updateExchangeFromUrl(baseUrl); + const exchangeDetails = exchangeInfo.details; + if (!exchangeDetails) { + throw Error(`exchange ${exchangeInfo.baseUrl} details not available`); + } + const exchangeWireInfo = exchangeInfo.wireInfo; + if (!exchangeWireInfo) { + throw Error( + `exchange ${exchangeInfo.baseUrl} wire details not available`, + ); + } const selectedDenoms = await this.getVerifiedWithdrawDenomList( baseUrl, @@ -1887,16 +1850,8 @@ export class Wallet { ) .reduce((a, b) => Amounts.add(a, b).amount); - const wireInfo = await this.getWireInfo(baseUrl); - - const wireFees = await this.q().get(Stores.exchangeWireFees, baseUrl); - if (!wireFees) { - // should never happen unless DB is inconsistent - throw Error(`no wire fees found for exchange ${baseUrl}`); - } - const exchangeWireAccounts: string[] = []; - for (let account of wireInfo.accounts) { + for (let account of exchangeWireInfo.accounts) { exchangeWireAccounts.push(account.url); } @@ -1910,17 +1865,14 @@ export class Wallet { } } - const possibleDenoms = - (await this.q() - .iterIndex(Stores.denominations.exchangeBaseUrlIndex, baseUrl) - .filter(d => d.isOffered) - .toArray()) || []; + const possibleDenoms = await oneShotIterIndex( + this.db, + Stores.denominations.exchangeBaseUrlIndex, + baseUrl) + .filter((d) => d.isOffered); const trustedAuditorPubs = []; - const currencyRecord = await this.q().get( - Stores.currencies, - amount.currency, - ); + const currencyRecord = await oneShotGet(this.db, Stores.currencies, amount.currency); if (currencyRecord) { trustedAuditorPubs.push( ...currencyRecord.auditors.map(a => a.auditorPub), @@ -1928,10 +1880,10 @@ export class Wallet { } let versionMatch; - if (exchangeInfo.protocolVersion) { + if (exchangeDetails.protocolVersion) { versionMatch = LibtoolVersion.compare( WALLET_PROTOCOL_VERSION, - exchangeInfo.protocolVersion, + exchangeDetails.protocolVersion, ); if ( @@ -1940,10 +1892,10 @@ export class Wallet { versionMatch.currentCmp === -1 ) { console.warn( - `wallet version ${WALLET_PROTOCOL_VERSION} might be outdated (exchange has ${exchangeInfo.protocolVersion}), checking for updates`, + `wallet version ${WALLET_PROTOCOL_VERSION} might be outdated (exchange has ${exchangeDetails.protocolVersion}), checking for updates`, ); if (isFirefox()) { - console.log("skipping update check on Firefox") + console.log("skipping update check on Firefox"); } else { chrome.runtime.requestUpdateCheck((status, details) => { console.log("update check status:", status); @@ -1956,7 +1908,7 @@ export class Wallet { earliestDepositExpiration, exchangeInfo, exchangeWireAccounts, - exchangeVersion: exchangeInfo.protocolVersion || "unknown", + exchangeVersion: exchangeDetails.protocolVersion || "unknown", isAudited, isTrusted, numOfferedDenoms: possibleDenoms.length, @@ -1965,7 +1917,7 @@ export class Wallet { trustedAuditorPubs, versionMatch, walletVersion: WALLET_PROTOCOL_VERSION, - wireFees, + wireFees: exchangeWireInfo, withdrawFee: acc, }; return ret; @@ -1975,8 +1927,15 @@ export class Wallet { exchangeBaseUrl: string, supportedTargetTypes: string[], ): Promise { - const wireInfo = await this.getWireInfo(exchangeBaseUrl); - for (let account of wireInfo.accounts) { + const exchangeRecord = await oneShotGet(this.db, Stores.exchanges, exchangeBaseUrl); + if (!exchangeRecord) { + throw Error(`Exchange '${exchangeBaseUrl}' not found.`); + } + const exchangeWireInfo = exchangeRecord.wireInfo; + if (!exchangeWireInfo) { + throw Error(`Exchange wire info for '${exchangeBaseUrl}' not found.`); + } + for (let account of exchangeWireInfo.accounts) { const paytoUri = new URI(account.url); if (supportedTargetTypes.includes(paytoUri.authority())) { return account.url; @@ -1986,235 +1945,173 @@ export class Wallet { } /** - * Update or add exchange DB entry by fetching the /keys information. + * Update or add exchange DB entry by fetching the /keys and /wire information. * Optionally link the reserve entry to the new or existing * exchange entry in then DB. */ - async updateExchangeFromUrl(baseUrl: string): Promise { + async updateExchangeFromUrl( + baseUrl: string, + force: boolean = false, + ): Promise { + const now = getTimestampNow(); baseUrl = canonicalizeBaseUrl(baseUrl); + + const r = await oneShotGet(this.db, Stores.exchanges, baseUrl); + if (!r) { + const newExchangeRecord: ExchangeRecord = { + baseUrl: baseUrl, + details: undefined, + wireInfo: undefined, + updateStatus: ExchangeUpdateStatus.FETCH_KEYS, + updateStarted: now, + }; + await oneShotPut(this.db, Stores.exchanges, newExchangeRecord); + } else { + runWithWriteTransaction(this.db, [Stores.exchanges], async (t) => { + const rec = await t.get(Stores.exchanges, baseUrl); + if (!rec) { + return; + } + if (rec.updateStatus != ExchangeUpdateStatus.NONE && !force) { + return; + } + rec.updateStarted = now; + rec.updateStatus = ExchangeUpdateStatus.FETCH_KEYS; + t.put(Stores.exchanges, rec); + }); + } + + await this.updateExchangeWithKeys(baseUrl); + await this.updateExchangeWithWireInfo(baseUrl); + + const updatedExchange = await oneShotGet(this.db, Stores.exchanges, baseUrl); + + if (!updatedExchange) { + // This should practically never happen + throw Error("exchange not found"); + } + return updatedExchange; + } + + private async setExchangeError( + baseUrl: string, + err: OperationError, + ): Promise { + const mut = (exchange: ExchangeRecord) => { + exchange.lastError = err; + return exchange; + }; + await oneShotMutate(this.db, Stores.exchanges, baseUrl, mut); + } + + /** + * Fetch the exchange's /keys and update our database accordingly. + * + * Exceptions thrown in this method must be caught and reported + * in the pending operations. + */ + private async updateExchangeWithKeys(baseUrl: string): Promise { + const existingExchangeRecord = await oneShotGet(this.db, Stores.exchanges, baseUrl); + + if (existingExchangeRecord?.updateStatus != ExchangeUpdateStatus.FETCH_KEYS) { + return; + } const keysUrl = new URI("keys") .absoluteTo(baseUrl) .addQuery("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION); - const keysResp = await this.http.get(keysUrl.href()); - if (keysResp.status !== 200) { - throw Error("/keys request failed"); + let keysResp; + try { + keysResp = await this.http.get(keysUrl.href()); + } catch (e) { + await this.setExchangeError(baseUrl, { + type: "network", + details: {}, + message: `Fetching keys failed: ${e.message}`, + }); + throw e; + } + let exchangeKeysJson: KeysJson; + try { + exchangeKeysJson = KeysJson.checked(keysResp.responseJson); + } catch (e) { + await this.setExchangeError(baseUrl, { + type: "protocol-violation", + details: {}, + message: `Parsing /keys response failed: ${e.message}`, + }); + throw e; } - const exchangeKeysJson = KeysJson.checked(keysResp.responseJson); - const exchangeWire = await this.getWireInfo(baseUrl); - return this.updateExchangeFromJson(baseUrl, exchangeKeysJson, exchangeWire); - } - private async suspendCoins(exchangeInfo: ExchangeRecord): Promise { - const resultSuspendedCoins = await this.q() - .iterIndex(Stores.coins.exchangeBaseUrlIndex, exchangeInfo.baseUrl) - .indexJoinLeft( - Stores.denominations.exchangeBaseUrlIndex, - e => e.exchangeBaseUrl, - ) - .fold( - ( - cd: JoinLeftResult, - suspendedCoins: CoinRecord[], - ) => { - if (!cd.right || !cd.right.isOffered) { - return Array.prototype.concat(suspendedCoins, [cd.left]); - } - return Array.prototype.concat(suspendedCoins); - }, - [], - ); - - const q = this.q(); - resultSuspendedCoins.map((c: CoinRecord) => { - Wallet.enableTracing && console.log("suspending coin", c); - c.suspended = true; - q.put(Stores.coins, c); - this.badge.showNotification(); - this.notifier.notify(); - }); - await q.finish(); - } - - private async updateExchangeFromJson( - baseUrl: string, - exchangeKeysJson: KeysJson, - wireMethodDetails: ExchangeWireJson, - ): Promise { - // FIXME: all this should probably be commited atomically - const updateTimeSec = getTalerStampSec(exchangeKeysJson.list_issue_date); - if (updateTimeSec === null) { - throw Error("invalid update time"); + const lastUpdateTimestamp = extractTalerStamp( + exchangeKeysJson.list_issue_date, + ); + if (!lastUpdateTimestamp) { + const m = `Parsing /keys response failed: invalid list_issue_date.`; + await this.setExchangeError(baseUrl, { + type: "protocol-violation", + details: {}, + message: m, + }); + throw Error(m); } if (exchangeKeysJson.denoms.length === 0) { - throw Error("exchange doesn't offer any denominations"); + const m = "exchange doesn't offer any denominations"; + await this.setExchangeError(baseUrl, { + type: "protocol-violation", + details: {}, + message: m, + }); + throw Error(m); } - const r = await this.q().get(Stores.exchanges, baseUrl); + const protocolVersion = exchangeKeysJson.version; + if (!protocolVersion) { + const m = "outdate exchange, no version in /keys response"; + await this.setExchangeError(baseUrl, { + type: "protocol-violation", + details: {}, + message: m, + }); + throw Error(m); + } - let exchangeInfo: ExchangeRecord; + const currency = Amounts.parseOrThrow(exchangeKeysJson.denoms[0].value) + .currency; - if (!r) { - exchangeInfo = { - auditors: exchangeKeysJson.auditors, - baseUrl, - currency: Amounts.parseOrThrow(exchangeKeysJson.denoms[0].value) - .currency, - lastUpdateTime: updateTimeSec, - lastUsedTime: 0, + const mutExchangeRecord = (r: ExchangeRecord) => { + if (r.updateStatus != ExchangeUpdateStatus.FETCH_KEYS) { + console.log("not updating, wrong state (concurrent modification?)"); + return undefined; + } + r.details = { + currency, + protocolVersion, + lastUpdateTime: lastUpdateTimestamp, masterPublicKey: exchangeKeysJson.master_public_key, + auditors: exchangeKeysJson.auditors, }; - Wallet.enableTracing && console.log("making fresh exchange"); - } else { - if (updateTimeSec < r.lastUpdateTime) { - Wallet.enableTracing && console.log("outdated /keys, not updating"); - return r; - } - exchangeInfo = r; - exchangeInfo.lastUpdateTime = updateTimeSec; - Wallet.enableTracing && console.log("updating old exchange"); - } - - const updatedExchangeInfo = await this.updateExchangeInfo( - exchangeInfo, - exchangeKeysJson, - ); - await this.suspendCoins(updatedExchangeInfo); - updatedExchangeInfo.protocolVersion = exchangeKeysJson.version; - - await this.q() - .put(Stores.exchanges, updatedExchangeInfo) - .finish(); - - let oldWireFees = await this.q().get(Stores.exchangeWireFees, baseUrl); - if (!oldWireFees) { - oldWireFees = { - exchangeBaseUrl: baseUrl, - feesForType: {}, - }; - } - - for (const paytoTargetType in wireMethodDetails.fees) { - let latestFeeStamp = 0; - const newFeeDetails = wireMethodDetails.fees[paytoTargetType]; - const oldFeeDetails = oldWireFees.feesForType[paytoTargetType] || []; - oldWireFees.feesForType[paytoTargetType] = oldFeeDetails; - for (const oldFee of oldFeeDetails) { - if (oldFee.endStamp > latestFeeStamp) { - latestFeeStamp = oldFee.endStamp; - } - } - for (const fee of newFeeDetails) { - const start = getTalerStampSec(fee.start_date); - if (start === null) { - console.error("invalid start stamp in fee", fee); - continue; - } - if (start < latestFeeStamp) { - continue; - } - const end = getTalerStampSec(fee.end_date); - if (end === null) { - console.error("invalid end stamp in fee", fee); - continue; - } - const wf: WireFee = { - closingFee: Amounts.parseOrThrow(fee.closing_fee), - endStamp: end, - sig: fee.sig, - startStamp: start, - wireFee: Amounts.parseOrThrow(fee.wire_fee), - }; - const valid: boolean = await this.cryptoApi.isValidWireFee( - paytoTargetType, - wf, - exchangeInfo.masterPublicKey, - ); - if (!valid) { - console.error("fee signature invalid", fee); - throw Error("fee signature invalid"); - } - oldFeeDetails.push(wf); - } - } - - await this.q().put(Stores.exchangeWireFees, oldWireFees); - - if (exchangeKeysJson.payback) { - for (const payback of exchangeKeysJson.payback) { - const denom = await this.q().getIndexed( - Stores.denominations.denomPubHashIndex, - payback.h_denom_pub, - ); - if (!denom) { - continue; - } - Wallet.enableTracing && console.log(`cashing back denom`, denom); - const coins = await this.q() - .iterIndex(Stores.coins.denomPubIndex, denom.denomPub) - .toArray(); - for (const coin of coins) { - this.payback(coin.coinPub); - } - } - } - - return updatedExchangeInfo; + r.updateStatus = ExchangeUpdateStatus.FETCH_WIRE; + r.lastError = undefined; + return r; + }; } - private async updateExchangeInfo( - exchangeInfo: ExchangeRecord, - newKeys: KeysJson, - ): Promise { - if (exchangeInfo.masterPublicKey !== newKeys.master_public_key) { - throw Error("public keys do not match"); + private async updateExchangeWithWireInfo(exchangeBaseUrl: string) { + exchangeBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl); + const reqUrl = new URI("wire") + .absoluteTo(exchangeBaseUrl) + .addQuery("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION); + const resp = await this.http.get(reqUrl.href()); + + const wiJson = resp.responseJson; + if (!wiJson) { + throw Error("/wire response malformed"); } - - const existingDenoms: { - [denomPub: string]: DenominationRecord; - } = await this.q() - .iterIndex( - Stores.denominations.exchangeBaseUrlIndex, - exchangeInfo.baseUrl, - ) - .fold( - (x: DenominationRecord, acc: typeof existingDenoms) => ( - (acc[x.denomPub] = x), acc - ), - {}, - ); - - const newDenoms: typeof existingDenoms = {}; - const newAndUnseenDenoms: typeof existingDenoms = {}; - - for (const d of newKeys.denoms) { - const dr = await this.denominationRecordFromKeys(exchangeInfo.baseUrl, d); - if (!(d.denom_pub in existingDenoms)) { - newAndUnseenDenoms[dr.denomPub] = dr; - } - newDenoms[dr.denomPub] = dr; - } - - for (const oldDenomPub in existingDenoms) { - if (!(oldDenomPub in newDenoms)) { - const d = existingDenoms[oldDenomPub]; - d.isOffered = false; - } - } - - await this.q() - .putAll( - Stores.denominations, - Object.keys(newAndUnseenDenoms).map(d => newAndUnseenDenoms[d]), - ) - .putAll( - Stores.denominations, - Object.keys(existingDenoms).map(d => existingDenoms[d]), - ) - .finish(); - return exchangeInfo; + const wireInfo = ExchangeWireJson.checked(wiJson); } + /** * Get detailed balance information, sliced by exchange and by currency. */ @@ -2253,127 +2150,85 @@ export class Wallet { entryEx[field] = Amounts.add(entryEx[field], amount).amount; } - function collectBalances(c: CoinRecord, balance: WalletBalance) { - if (c.suspended) { - return balance; - } - if (c.status === CoinStatus.Fresh) { - addTo(balance, "available", c.currentAmount, c.exchangeBaseUrl); - return balance; - } - if (c.status === CoinStatus.Dirty) { - addTo(balance, "pendingIncoming", c.currentAmount, c.exchangeBaseUrl); - addTo(balance, "pendingIncomingDirty", c.currentAmount, c.exchangeBaseUrl); - return balance; - } - return balance; - } - - function collectPendingWithdraw(r: ReserveRecord, balance: WalletBalance) { - if (!r.timestamp_confirmed) { - return balance; - } - let amount = Amounts.getZero(r.requested_amount.currency); - /* - let amount = r.current_amount; - if (!amount) { - amount = r.requested_amount; - } - */ - amount = Amounts.add(amount, r.precoin_amount).amount; - if (Amounts.cmp(smallestWithdraw[r.exchange_base_url], amount) < 0) { - addTo(balance, "pendingIncoming", amount, r.exchange_base_url); - addTo(balance, "pendingIncomingWithdraw", amount, r.exchange_base_url); - } - return balance; - } - - function collectPaybacks(r: ReserveRecord, balance: WalletBalance) { - if (!r.hasPayback) { - return balance; - } - if ( - Amounts.cmp(smallestWithdraw[r.exchange_base_url], r.current_amount!) < - 0 - ) { - addTo(balance, "paybackAmount", r.current_amount!, r.exchange_base_url); - } - return balance; - } - - function collectPendingRefresh( - r: RefreshSessionRecord, - balance: WalletBalance, - ) { - // Don't count finished refreshes, since the refresh already resulted - // in coins being added to the wallet. - if (r.finished) { - return balance; - } - addTo(balance, "pendingIncoming", r.valueOutput, r.exchangeBaseUrl); - addTo(balance, "pendingIncomingRefresh", r.valueOutput, r.exchangeBaseUrl); - - return balance; - } - - function collectPayments(t: PurchaseRecord, balance: WalletBalance) { - if (t.finished) { - return balance; - } - for (const c of t.payReq.coins) { - addTo( - balance, - "pendingPayment", - Amounts.parseOrThrow(c.contribution), - c.exchange_url, - ); - } - return balance; - } - - function collectSmallestWithdraw( - e: JoinResult, - sw: any, - ) { - let min = sw[e.left.baseUrl]; - const v = Amounts.add(e.right.value, e.right.feeWithdraw).amount; - if (!min) { - min = v; - } else if (Amounts.cmp(v, min) < 0) { - min = v; - } - sw[e.left.baseUrl] = min; - return sw; - } - const balanceStore = { byCurrency: {}, byExchange: {}, }; - // Mapping from exchange pub to smallest - // possible amount we can withdraw - let smallestWithdraw: { [baseUrl: string]: AmountJson } = {}; - smallestWithdraw = await this.q() - .iter(Stores.exchanges) - .indexJoin(Stores.denominations.exchangeBaseUrlIndex, x => x.baseUrl) - .fold(collectSmallestWithdraw, {}); + await runWithWriteTransaction(this.db, [Stores.coins, Stores.refresh, Stores.reserves, Stores.purchases], async (tx) => { + await tx.iter(Stores.coins).forEach((c) => { + if (c.suspended) { + return; + } + if (c.status === CoinStatus.Fresh) { + addTo(balanceStore, "available", c.currentAmount, c.exchangeBaseUrl); + } + if (c.status === CoinStatus.Dirty) { + addTo(balanceStore, "pendingIncoming", c.currentAmount, c.exchangeBaseUrl); + addTo( + balanceStore, + "pendingIncomingDirty", + c.currentAmount, + c.exchangeBaseUrl, + ); + } + }); + await tx.iter(Stores.refresh).forEach((r) => { + // Don't count finished refreshes, since the refresh already resulted + // in coins being added to the wallet. + if (r.finished) { + return; + } + addTo(balanceStore, "pendingIncoming", r.valueOutput, r.exchangeBaseUrl); + addTo( + balanceStore, + "pendingIncomingRefresh", + r.valueOutput, + r.exchangeBaseUrl, + ); + }); - const tx = this.q(); - tx.iter(Stores.coins).fold(collectBalances, balanceStore); - tx.iter(Stores.refresh).fold(collectPendingRefresh, balanceStore); - tx.iter(Stores.reserves).fold(collectPendingWithdraw, balanceStore); - tx.iter(Stores.reserves).fold(collectPaybacks, balanceStore); - tx.iter(Stores.purchases).fold(collectPayments, balanceStore); - await tx.finish(); - Wallet.enableTracing && console.log("computed balances:", balanceStore) + await tx.iter(Stores.reserves).forEach((r) => { + if (!r.timestamp_confirmed) { + return; + } + let amount = Amounts.getZero(r.requested_amount.currency); + amount = Amounts.add(amount, r.precoin_amount).amount; + addTo(balanceStore, "pendingIncoming", amount, r.exchange_base_url); + addTo(balanceStore, "pendingIncomingWithdraw", amount, r.exchange_base_url); + }); + + await tx.iter(Stores.reserves).forEach((r) => { + if (!r.hasPayback) { + return; + } + addTo(balanceStore, "paybackAmount", r.current_amount!, r.exchange_base_url); + return balanceStore; + }); + + await tx.iter(Stores.purchases).forEach((t) => { + if (t.finished) { + return; + } + for (const c of t.payReq.coins) { + addTo( + balanceStore, + "pendingPayment", + Amounts.parseOrThrow(c.contribution), + c.exchange_url, + ); + } + }); + }); + + Wallet.enableTracing && console.log("computed balances:", balanceStore); return balanceStore; } async createRefreshSession( oldCoinPub: string, ): Promise { - const coin = await this.q().get(Stores.coins, oldCoinPub); + const coin = await oneShotGet(this.db, Stores.coins, oldCoinPub); if (!coin) { throw Error("coin not found"); @@ -2389,7 +2244,7 @@ export class Wallet { throw Error("db inconsistent"); } - const oldDenom = await this.q().get(Stores.denominations, [ + const oldDenom = await oneShotGet(this.db, Stores.denominations, [ exchange.baseUrl, coin.denomPub, ]); @@ -2398,9 +2253,7 @@ export class Wallet { throw Error("db inconsistent"); } - const availableDenoms: DenominationRecord[] = await this.q() - .iterIndex(Stores.denominations.exchangeBaseUrlIndex, exchange.baseUrl) - .toArray(); + const availableDenoms: DenominationRecord[] = await oneShotIterIndex(this.db, Stores.denominations.exchangeBaseUrlIndex, exchange.baseUrl).toArray(); const availableAmount = Amounts.sub(coin.currentAmount, oldDenom.feeRefresh) .amount; @@ -2421,7 +2274,7 @@ export class Wallet { )} too small`, ); coin.status = CoinStatus.Useless; - await this.q().put(Stores.coins, coin); + await oneShotPut(this.db, Stores.coins, coin); this.notifier.notify(); return undefined; } @@ -2445,16 +2298,16 @@ export class Wallet { return c; } + let key; + // Store refresh session and subtract refreshed amount from // coin in the same transaction. - const query = this.q(); - query - .put(Stores.refresh, refreshSession, "refreshKey") - .mutate(Stores.coins, coin.coinPub, mutateCoin); - await query.finish(); + await runWithWriteTransaction(this.db, [Stores.refresh, Stores.coins], async (tx) => { + key = await tx.put(Stores.refresh, refreshSession); + await tx.mutate(Stores.coins, coin.coinPub, mutateCoin); + }); this.notifier.notify(); - const key = query.key("refreshKey"); if (!key || typeof key !== "number") { throw Error("insert failed"); } @@ -2466,18 +2319,20 @@ export class Wallet { async refresh(oldCoinPub: string): Promise { const refreshImpl = async () => { - const oldRefreshSessions = await this.q() - .iter(Stores.refresh) - .toArray(); + const oldRefreshSessions = await oneShotIter(this.db, Stores.refresh).toArray(); for (const session of oldRefreshSessions) { if (session.finished) { continue; } Wallet.enableTracing && - console.log("waiting for unfinished old refresh session for", oldCoinPub, session); + console.log( + "waiting for unfinished old refresh session for", + oldCoinPub, + session, + ); await this.continueRefreshSession(session); } - const coin = await this.q().get(Stores.coins, oldCoinPub); + const coin = await oneShotGet(this.db, Stores.coins, oldCoinPub); if (!coin) { console.warn("can't refresh, coin not in database"); return; @@ -2486,7 +2341,11 @@ export class Wallet { coin.status === CoinStatus.Useless || coin.status === CoinStatus.Fresh ) { - Wallet.enableTracing && console.log("not refreshing due to coin status", CoinStatus[coin.status]) + Wallet.enableTracing && + console.log( + "not refreshing due to coin status", + CoinStatus[coin.status], + ); return; } const refreshSession = await this.createRefreshSession(oldCoinPub); @@ -2520,10 +2379,7 @@ export class Wallet { } if (typeof refreshSession.norevealIndex !== "number") { await this.refreshMelt(refreshSession); - const r = await this.q().get( - Stores.refresh, - refreshSession.id, - ); + const r = await oneShotGet(this.db, Stores.refresh, refreshSession.id); if (!r) { throw Error("refresh session does not exist anymore"); } @@ -2539,10 +2395,8 @@ export class Wallet { return; } - const coin = await this.q().get( - Stores.coins, - refreshSession.meltCoinPub, - ); + const coin = await oneShotGet(this.db, Stores.coins, refreshSession.meltCoinPub); + if (!coin) { console.error("can't melt coin, it does not exist"); return; @@ -2579,9 +2433,8 @@ export class Wallet { refreshSession.norevealIndex = norevealIndex; - await this.q() - .put(Stores.refresh, refreshSession) - .finish(); + await oneShotPut(this.db, Stores.refresh, refreshSession); + this.notifier.notify(); } @@ -2598,10 +2451,7 @@ export class Wallet { throw Error("refresh index error"); } - const meltCoinRecord = await this.q().get( - Stores.coins, - refreshSession.meltCoinPub, - ); + const meltCoinRecord = await oneShotGet(this.db, Stores.coins, refreshSession.meltCoinPub); if (!meltCoinRecord) { throw Error("inconsistent database"); } @@ -2657,10 +2507,7 @@ export class Wallet { return; } - const exchange = await this.q().get( - Stores.exchanges, - refreshSession.exchangeBaseUrl, - ); + const exchange = await this.findExchange(refreshSession.exchangeBaseUrl); if (!exchange) { console.error(`exchange ${refreshSession.exchangeBaseUrl} not found`); return; @@ -2669,7 +2516,7 @@ export class Wallet { const coins: CoinRecord[] = []; for (let i = 0; i < respJson.ev_sigs.length; i++) { - const denom = await this.q().get(Stores.denominations, [ + const denom = await oneShotGet(this.db, Stores.denominations, [ refreshSession.exchangeBaseUrl, refreshSession.newDenoms[i], ]); @@ -2702,17 +2549,25 @@ export class Wallet { refreshSession.finished = true; - await this.q() - .putAll(Stores.coins, coins) - .put(Stores.refresh, refreshSession) - .finish(); + await runWithWriteTransaction(this.db, [Stores.coins, Stores.refresh], async (tx) => { + for (let coin of coins) { + await tx.put(Stores.coins, coin); + } + await tx.put(Stores.refresh, refreshSession); + }); this.notifier.notify(); } + async findExchange(exchangeBaseUrl: string): Promise { + return await oneShotGet(this.db, Stores.exchanges, exchangeBaseUrl); + } + /** * Retrive the full event history for this wallet. */ - async getHistory(historyQuery?: HistoryQuery): Promise<{ history: HistoryRecord[] }> { + async getHistory( + historyQuery?: HistoryQuery, + ): Promise<{ history: HistoryRecord[] }> { const history: HistoryRecord[] = []; // FIXME: do pagination instead of generating the full history @@ -2721,9 +2576,7 @@ export class Wallet { // This works as timestamps are guaranteed to be monotonically // increasing even - const proposals = await this.q() - .iter(Stores.proposals) - .toArray(); + const proposals = await oneShotIter(this.db, Stores.proposals).toArray(); for (const p of proposals) { history.push({ detail: { @@ -2735,7 +2588,7 @@ export class Wallet { }); } - const withdrawals = await this.q().iter(Stores.withdrawals).toArray() + const withdrawals = await oneShotIter(this.db, Stores.withdrawals).toArray(); for (const w of withdrawals) { history.push({ detail: { @@ -2746,9 +2599,7 @@ export class Wallet { }); } - const purchases = await this.q() - .iter(Stores.purchases) - .toArray(); + const purchases = await oneShotIter(this.db, Stores.purchases).toArray(); for (const p of purchases) { history.push({ detail: { @@ -2787,9 +2638,8 @@ export class Wallet { } } - const reserves: ReserveRecord[] = await this.q() - .iter(Stores.reserves) - .toArray(); + const reserves = await oneShotIter(this.db, Stores.reserves).toArray(); + for (const r of reserves) { history.push({ detail: { @@ -2813,9 +2663,7 @@ export class Wallet { } } - const tips: TipRecord[] = await this.q() - .iter(Stores.tips) - .toArray(); + const tips: TipRecord[] = await oneShotIter(this.db, Stores.tips).toArray(); for (const tip of tips) { history.push({ detail: { @@ -2835,78 +2683,87 @@ export class Wallet { } async getPendingOperations(): Promise { + const pendingOperations: PendingOperationInfo[] = []; + const exchanges = await this.getExchanges(); + for (let e of exchanges) { + switch (e.updateStatus) { + case ExchangeUpdateStatus.NONE: + if (!e.details) { + pendingOperations.push({ + type: "bug", + message: + "Exchange record does not have details, but no update in progress.", + details: { + exchangeBaseUrl: e.baseUrl, + }, + }); + } + break; + case ExchangeUpdateStatus.FETCH_KEYS: + pendingOperations.push({ + type: "exchange-update", + stage: "fetch-keys", + exchangeBaseUrl: e.baseUrl, + }); + break; + case ExchangeUpdateStatus.FETCH_WIRE: + pendingOperations.push({ + type: "exchange-update", + stage: "fetch-wire", + exchangeBaseUrl: e.baseUrl, + }); + break; + } + } return { - pendingOperations: [] + pendingOperations, }; } async getDenoms(exchangeUrl: string): Promise { - const denoms = await this.q() - .iterIndex(Stores.denominations.exchangeBaseUrlIndex, exchangeUrl) - .toArray(); + const denoms = await oneShotIterIndex(this.db, Stores.denominations.exchangeBaseUrlIndex, exchangeUrl).toArray(); return denoms; } async getProposal( proposalId: number, ): Promise { - const proposal = await this.q().get(Stores.proposals, proposalId); + const proposal = await oneShotGet(this.db, Stores.proposals, proposalId); return proposal; } async getExchanges(): Promise { - return this.q() - .iter(Stores.exchanges) - .toArray(); + return await oneShotIter(this.db, Stores.exchanges).toArray(); } async getCurrencies(): Promise { - return this.q() - .iter(Stores.currencies) - .toArray(); + return await oneShotIter(this.db, Stores.currencies).toArray(); } async updateCurrency(currencyRecord: CurrencyRecord): Promise { Wallet.enableTracing && console.log("updating currency to", currencyRecord); - await this.q() - .put(Stores.currencies, currencyRecord) - .finish(); + await oneShotPut(this.db, Stores.currencies, currencyRecord); this.notifier.notify(); } async getReserves(exchangeBaseUrl: string): Promise { - return this.q() - .iter(Stores.reserves) - .filter((r: ReserveRecord) => r.exchange_base_url === exchangeBaseUrl) - .toArray(); + return await oneShotIter(this.db, Stores.reserves).filter((r) => r.exchange_base_url === exchangeBaseUrl); } async getCoins(exchangeBaseUrl: string): Promise { - return this.q() - .iter(Stores.coins) - .filter((c: CoinRecord) => c.exchangeBaseUrl === exchangeBaseUrl) - .toArray(); + return await oneShotIter(this.db, Stores.coins).filter((c) => c.exchangeBaseUrl === exchangeBaseUrl); } async getPreCoins(exchangeBaseUrl: string): Promise { - return this.q() - .iter(Stores.precoins) - .filter((c: PreCoinRecord) => c.exchangeBaseUrl === exchangeBaseUrl) - .toArray(); + return await oneShotIter(this.db, Stores.precoins).filter((c) => c.exchangeBaseUrl === exchangeBaseUrl); } - async hashContract(contract: ContractTerms): Promise { + private async hashContract(contract: ContractTerms): Promise { return this.cryptoApi.hashString(canonicalJson(contract)); } - async getCurrencyRecord( - currency: string, - ): Promise { - return this.q().get(Stores.currencies, currency); - } - async payback(coinPub: string): Promise { - let coin = await this.q().get(Stores.coins, coinPub); + let coin = await oneShotGet(this.db, Stores.coins, coinPub); if (!coin) { throw Error(`Coin ${coinPub} not found, can't request payback`); } @@ -2914,7 +2771,7 @@ export class Wallet { if (!reservePub) { throw Error(`Can't request payback for a refreshed coin`); } - const reserve = await this.q().get(Stores.reserves, reservePub); + const reserve = await oneShotGet(this.db, Stores.reserves, reservePub); if (!reserve) { throw Error(`Reserve of coin ${coinPub} not found`); } @@ -2932,9 +2789,10 @@ export class Wallet { // technically we might update reserve status before we get the response // from the reserve for the payback request. reserve.hasPayback = true; - await this.q() - .put(Stores.coins, coin) - .put(Stores.reserves, reserve); + await runWithWriteTransaction(this.db, [Stores.coins, Stores.reserves], async (tx) => { + await tx.put(Stores.coins, coin!!); + await tx.put(Stores.reserves, reserve); + }); this.notifier.notify(); const paybackRequest = await this.cryptoApi.createPaybackRequest(coin); @@ -2947,17 +2805,17 @@ export class Wallet { if (paybackConfirmation.reserve_pub !== coin.reservePub) { throw Error(`Coin's reserve doesn't match reserve on payback`); } - coin = await this.q().get(Stores.coins, coinPub); + coin = await oneShotGet(this.db, Stores.coins, coinPub); if (!coin) { throw Error(`Coin ${coinPub} not found, can't confirm payback`); } coin.status = CoinStatus.PaybackDone; - await this.q().put(Stores.coins, coin); + await oneShotPut(this.db, Stores.coins, coin); this.notifier.notify(); await this.updateReserve(reservePub!); } - async denominationRecordFromKeys( + private async denominationRecordFromKeys( exchangeBaseUrl: string, denomIn: Denomination, ): Promise { @@ -2983,20 +2841,17 @@ export class Wallet { } async withdrawPaybackReserve(reservePub: string): Promise { - const reserve = await this.q().get(Stores.reserves, reservePub); + const reserve = await oneShotGet(this.db, Stores.reserves, reservePub); if (!reserve) { throw Error(`Reserve ${reservePub} does not exist`); } reserve.hasPayback = false; - await this.q().put(Stores.reserves, reserve); + await oneShotPut(this.db, Stores.reserves, reserve); this.depleteReserve(reserve); } async getPaybackReserves(): Promise { - return await this.q() - .iter(Stores.reserves) - .filter(r => r.hasPayback) - .toArray(); + return await oneShotIter(this.db, Stores.reserves).filter(r => r.hasPayback); } /** @@ -3009,13 +2864,16 @@ export class Wallet { async getSenderWireInfos(): Promise { const m: { [url: string]: Set } = {}; - await this.q() - .iter(Stores.exchangeWireFees) - .map(x => { - const s = (m[x.exchangeBaseUrl] = m[x.exchangeBaseUrl] || new Set()); - Object.keys(x.feesForType).map(k => s.add(k)); - }) - .run(); + + await oneShotIter(this.db, Stores.exchanges).forEach((x) => { + const wi = x.wireInfo; + if (!wi) { + return; + } + const s = (m[x.baseUrl] = m[x.baseUrl] || new Set()); + Object.keys(wi.feesForType).map(k => s.add(k)); + }); + Wallet.enableTracing && console.log(m); const exchangeWireTypes: { [url: string]: string[] } = {}; Object.keys(m).map(e => { @@ -3023,12 +2881,10 @@ export class Wallet { }); const senderWiresSet: Set = new Set(); - await this.q() - .iter(Stores.senderWires) - .map(x => { - senderWiresSet.add(x.paytoUri); - }) - .run(); + await oneShotIter(this.db, Stores.senderWires).forEach((x) => { + senderWiresSet.add(x.paytoUri); + }); + const senderWires: string[] = Array.from(senderWiresSet); return { @@ -3049,11 +2905,15 @@ export class Wallet { return; } const stampSecNow = Math.floor(new Date().getTime() / 1000); - const exchange = await this.q().get(Stores.exchanges, req.exchange); + const exchange = await this.findExchange(req.exchange); if (!exchange) { console.error(`Exchange ${req.exchange} not known to the wallet`); return; } + const exchangeDetails = exchange.details; + if (!exchangeDetails) { + throw Error("exchange information needs to be updated first."); + } Wallet.enableTracing && console.log("selecting coins for return:", req); const cds = await this.getCoinsForReturn(req.exchange, req.amount); Wallet.enableTracing && console.log(cds); @@ -3073,7 +2933,7 @@ export class Wallet { amount: Amounts.toString(req.amount), auditors: [], exchanges: [ - { master_pub: exchange.masterPublicKey, url: exchange.baseUrl }, + { master_pub: exchangeDetails.masterPublicKey, url: exchange.baseUrl }, ], extra: {}, fulfillment_url: "", @@ -3114,10 +2974,12 @@ export class Wallet { wire: req.senderWire, }; - await this.q() - .put(Stores.coinsReturns, coinsReturnRecord) - .putAll(Stores.coins, payCoinInfo.updatedCoins) - .finish(); + await runWithWriteTransaction(this.db, [Stores.coinsReturns, Stores.coins], async (tx) => { + await tx.put(Stores.coinsReturns, coinsReturnRecord); + for (let c of payCoinInfo.updatedCoins) { + await tx.put(Stores.coins, c); + } + }); this.badge.showNotification(); this.notifier.notify(); @@ -3167,10 +3029,7 @@ export class Wallet { // FIXME: verify signature // For every successful deposit, we replace the old record with an updated one - const currentCrr = await this.q().get( - Stores.coinsReturns, - coinsReturnRecord.contractTermsHash, - ); + const currentCrr = await oneShotGet(this.db, Stores.coinsReturns, coinsReturnRecord.contractTermsHash); if (!currentCrr) { console.error("database inconsistent"); continue; @@ -3180,7 +3039,7 @@ export class Wallet { nc.depositedSig = respJson.sig; } } - await this.q().put(Stores.coinsReturns, currentCrr); + await oneShotPut(this.db, Stores.coinsReturns, currentCrr); this.notifier.notify(); } } @@ -3220,9 +3079,7 @@ export class Wallet { const hc = refundResponse.h_contract_terms; // Add the refund permissions to the purchase within a DB transaction - await this.q() - .mutate(Stores.purchases, hc, f) - .finish(); + await oneShotMutate(this.db, Stores.purchases, hc, f); this.notifier.notify(); await this.submitRefunds(hc); @@ -3257,7 +3114,7 @@ export class Wallet { } private async submitRefunds(contractTermsHash: string): Promise { - const purchase = await this.q().get(Stores.purchases, contractTermsHash); + const purchase = await oneShotGet(this.db, Stores.purchases, contractTermsHash); if (!purchase) { console.error( "not submitting refunds, contract terms not found:", @@ -3320,10 +3177,10 @@ export class Wallet { return c; }; - await this.q() - .mutate(Stores.purchases, contractTermsHash, transformPurchase) - .mutate(Stores.coins, perm.coin_pub, transformCoin) - .finish(); + await runWithWriteTransaction(this.db, [Stores.purchases, Stores.coins], async (tx) => { + await tx.mutate(Stores.purchases, contractTermsHash, transformPurchase); + await tx.mutate(Stores.coins, perm.coin_pub, transformCoin); + }); this.refresh(perm.coin_pub); } @@ -3334,7 +3191,7 @@ export class Wallet { async getPurchase( contractTermsHash: string, ): Promise { - return this.q().get(Stores.purchases, contractTermsHash); + return oneShotGet(this.db, Stores.purchases, contractTermsHash); } async getFullRefundFees( @@ -3343,10 +3200,7 @@ export class Wallet { if (refundPermissions.length === 0) { throw Error("no refunds given"); } - const coin0 = await this.q().get( - Stores.coins, - refundPermissions[0].coin_pub, - ); + const coin0 = await oneShotGet(this.db, Stores.coins, refundPermissions[0].coin_pub); if (!coin0) { throw Error("coin not found"); } @@ -3354,18 +3208,15 @@ export class Wallet { Amounts.parseOrThrow(refundPermissions[0].refund_amount).currency, ); - const denoms = await this.q() - .iterIndex( - Stores.denominations.exchangeBaseUrlIndex, - coin0.exchangeBaseUrl, - ) - .toArray(); + const denoms = await oneShotIterIndex(this.db, Stores.denominations.exchangeBaseUrlIndex, + coin0.exchangeBaseUrl).toArray() + for (const rp of refundPermissions) { - const coin = await this.q().get(Stores.coins, rp.coin_pub); + const coin = await oneShotGet(this.db, Stores.coins, rp.coin_pub); if (!coin) { throw Error("coin not found"); } - const denom = await this.q().get(Stores.denominations, [ + const denom = await oneShotGet(this.db, Stores.denominations, [ coin0.exchangeBaseUrl, coin.denomPub, ]); @@ -3407,19 +3258,13 @@ export class Wallet { tipId: string, merchantOrigin: string, ): Promise { - let tipRecord = await this.q().get(Stores.tips, [tipId, merchantOrigin]); + let tipRecord = await oneShotGet(this.db, Stores.tips, [tipId, merchantOrigin]); if (!tipRecord) { throw Error("tip not in database"); } tipRecord.accepted = true; - - // Create one transactional query, within this transaction - // both the tip will be marked as accepted and coins - // already withdrawn will be untainted. - await this.q() - .put(Stores.tips, tipRecord) - .finish(); + await oneShotPut(this.db, Stores.tips, tipRecord); if (tipRecord.pickedUp) { console.log("tip already picked up"); @@ -3437,7 +3282,7 @@ export class Wallet { ); const coinPubs: string[] = planchets.map(x => x.coinPub); - await this.q().mutate(Stores.tips, [tipId, merchantOrigin], r => { + await oneShotMutate(this.db, Stores.tips, [tipId, merchantOrigin], (r) => { if (!r.planchets) { r.planchets = planchets; r.coinPubs = coinPubs; @@ -3448,7 +3293,7 @@ export class Wallet { this.notifier.notify(); } - tipRecord = await this.q().get(Stores.tips, [tipId, merchantOrigin]); + tipRecord = await oneShotGet(this.db, Stores.tips, [tipId, merchantOrigin]); if (!tipRecord) { throw Error("tip not in database"); } @@ -3497,15 +3342,14 @@ export class Wallet { reservePub: response.reserve_pub, withdrawSig: response.reserve_sigs[i].reserve_sig, }; - await this.q().put(Stores.precoins, preCoin); + await oneShotPut(this.db, Stores.precoins, preCoin); await this.processPreCoin(preCoin.coinPub); } tipRecord.pickedUp = true; - await this.q() - .put(Stores.tips, tipRecord) - .finish(); + await oneShotPut(this.db, Stores.tips, tipRecord); + this.notifier.notify(); this.badge.showNotification(); return; @@ -3529,10 +3373,11 @@ export class Wallet { let amount = Amounts.parseOrThrow(tipPickupStatus.amount); - let tipRecord = await this.q().get(Stores.tips, [ + let tipRecord = await oneShotGet(this.db, Stores.tips, [ res.tipId, res.merchantOrigin, - ]); + ]) + if (!tipRecord) { const withdrawDetails = await this.getWithdrawDetailsForAmount( tipPickupStatus.exchange_url, @@ -3558,7 +3403,7 @@ export class Wallet { withdrawDetails.withdrawFee, ).amount, }; - await this.q().put(Stores.tips, tipRecord); + await oneShotPut(this.db, Stores.tips, tipRecord); } const tipStatus: TipStatus = { @@ -3578,7 +3423,7 @@ export class Wallet { } async abortFailedPayment(contractTermsHash: string): Promise { - const purchase = await this.q().get(Stores.purchases, contractTermsHash); + const purchase = await oneShotGet(this.db, Stores.purchases, contractTermsHash); if (!purchase) { throw Error("Purchase not found, unable to abort with refund"); } @@ -3595,7 +3440,7 @@ export class Wallet { // From now on, we can't retry payment anymore, // so mark this in the DB in case the /pay abort // does not complete on the first try. - await this.q().put(Stores.purchases, purchase); + await oneShotPut(this.db, Stores.purchases, purchase); let resp; @@ -3616,15 +3461,14 @@ export class Wallet { const refundResponse = MerchantRefundResponse.checked(resp.responseJson); await this.acceptRefundResponse(refundResponse); - const markAbortDone = (p: PurchaseRecord) => { + await runWithWriteTransaction(this.db, [Stores.purchases], async (tx) => { + const p = await tx.get(Stores.purchases, purchase.contractTermsHash); + if (!p) { + return; + } p.abortDone = true; - return p; - }; - await this.q().mutate( - Stores.purchases, - purchase.contractTermsHash, - markAbortDone, - ); + await tx.put(Stores.purchases, p); + }); } /** @@ -3684,7 +3528,7 @@ export class Wallet { } async getPurchaseDetails(hc: string): Promise { - const purchase = await this.q().get(Stores.purchases, hc); + const purchase = await oneShotGet(this.db, Stores.purchases, hc); if (!purchase) { throw Error("unknown purchase"); } diff --git a/src/walletTypes.ts b/src/walletTypes.ts index e632cd38b..b227ca816 100644 --- a/src/walletTypes.ts +++ b/src/walletTypes.ts @@ -34,8 +34,7 @@ import { CoinRecord, DenominationRecord, ExchangeRecord, - ExchangeWireFeesRecord, - TipRecord, + ExchangeWireInfo, } from "./dbTypes"; import { CoinPaySig, ContractTerms, PayReq } from "./talerTypes"; @@ -98,7 +97,7 @@ export interface ReserveCreationInfo { /** * Wire fees from the exchange. */ - wireFees: ExchangeWireFeesRecord; + wireFees: ExchangeWireInfo; /** * Does the wallet know about an auditor for @@ -475,7 +474,6 @@ export interface PreparePayResultError { error: string; } - export interface PreparePayResultPaid { status: "paid"; contractTerms: ContractTerms; @@ -517,18 +515,40 @@ export interface WalletDiagnostics { } export interface PendingWithdrawOperation { - type: "withdraw" + type: "withdraw"; } export interface PendingRefreshOperation { - type: "refresh" + type: "refresh"; } export interface PendingPayOperation { - type: "pay" + type: "pay"; } -export type PendingOperationInfo = PendingWithdrawOperation +export interface OperationError { + type: string; + message: string; + details: any; +} + +export interface PendingExchangeUpdateOperation { + type: "exchange-update"; + stage: string; + exchangeBaseUrl: string; + lastError?: OperationError; +} + +export interface PendingBugOperation { + type: "bug"; + message: string; + details: any; +} + +export type PendingOperationInfo = + | PendingWithdrawOperation + | PendingBugOperation + | PendingExchangeUpdateOperation; export interface PendingOperationsResponse { pendingOperations: PendingOperationInfo[]; @@ -541,4 +561,24 @@ export interface HistoryQuery { * Level 1: All events. */ level: number; -} \ No newline at end of file +} + +export interface Timestamp { + /** + * Timestamp in milliseconds. + */ + t_ms: number; +} + +export interface Duration { + /** + * Duration in milliseconds. + */ + d_ms: number; +} + +export function getTimestampNow(): Timestamp { + return { + t_ms: new Date().getTime(), + }; +} diff --git a/src/webex/messages.ts b/src/webex/messages.ts index 78a1a1fd0..3f6e5cc4a 100644 --- a/src/webex/messages.ts +++ b/src/webex/messages.ts @@ -73,14 +73,6 @@ export interface MessageMap { request: { baseUrl: string }; response: dbTypes.ExchangeRecord; }; - "currency-info": { - request: { name: string }; - response: dbTypes.CurrencyRecord; - }; - "hash-contract": { - request: { contract: object }; - response: string; - }; "reserve-creation-info": { request: { baseUrl: string; amount: AmountJson }; response: walletTypes.ReserveCreationInfo; @@ -145,14 +137,6 @@ export interface MessageMap { request: {}; response: void; }; - "log-and-display-error": { - request: any; - response: void; - }; - "get-report": { - request: { reportUid: string }; - response: void; - }; "get-purchase-details": { request: { contractTermsHash: string }; response: walletTypes.PurchaseDetails; diff --git a/src/webex/pages/error.html b/src/webex/pages/error.html deleted file mode 100644 index 51a8fd73a..000000000 --- a/src/webex/pages/error.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - Taler Wallet: Error Occured - - - - - - - - - -
- - diff --git a/src/webex/pages/error.tsx b/src/webex/pages/error.tsx deleted file mode 100644 index dee8ce44e..000000000 --- a/src/webex/pages/error.tsx +++ /dev/null @@ -1,129 +0,0 @@ -/* - This file is part of TALER - (C) 2015-2016 GNUnet e.V. - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - TALER; see the file COPYING. If not, see - */ - - -/** - * Page shown to the user to confirm creation - * of a reserve, usually requested by the bank. - * - * @author Florian Dold - */ - - -import * as React from "react"; -import * as ReactDOM from "react-dom"; -import URI = require("urijs"); - -import * as wxApi from "../wxApi"; - -import { Collapsible } from "../renderHtml"; - -interface ErrorProps { - report: any; -} - -class ErrorView extends React.Component { - render(): JSX.Element { - const report = this.props.report; - if (!report) { - return ( -
-

Error Report Not Found

-

This page is supposed to display an error reported by the GNU Taler wallet, - but the corresponding error report can't be found.

-

Maybe the error occured before the browser was restarted or the wallet was reloaded.

-
- ); - } - try { - switch (report.name) { - case "pay-post-failed": { - const summary = report.contractTerms.summary || report.contractTerms.order_id; - return ( -
- ); - } - default: - return ( -
-

Unknown Error

- The GNU Taler wallet reported an unknown error. Here are the details: -
-                {JSON.stringify(report, null, " ")}
-              
-
- ); - } - } catch (e) { - return ( -
-

Error

- The GNU Taler wallet reported an error. Here are the details: -
-              {JSON.stringify(report, null, " ")}
-            
- A detailed error report could not be generated: -
-              {e.toString()}
-            
-
- ); - } - } -} - -async function main() { - const url = new URI(document.location.href); - const query: any = URI.parseQuery(url.query()); - - const container = document.getElementById("container"); - if (!container) { - console.error("fatal: can't mount component, countainer missing"); - return; - } - - // report that we'll render, either looked up from the - // logging module or synthesized here for fixed/fatal errors - let report; - - const reportUid: string = query.reportUid; - if (!reportUid) { - report = { - name: "missing-error", - }; - } else { - report = await wxApi.getReport(reportUid); - } - - ReactDOM.render(, container); -} - -document.addEventListener("DOMContentLoaded", () => main()); diff --git a/src/webex/pages/logs.html b/src/webex/pages/logs.html deleted file mode 100644 index 9545269e3..000000000 --- a/src/webex/pages/logs.html +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - Taler Wallet: Logs - - - - - - - - - - - -
- - diff --git a/src/webex/pages/logs.tsx b/src/webex/pages/logs.tsx deleted file mode 100644 index c4fe670a2..000000000 --- a/src/webex/pages/logs.tsx +++ /dev/null @@ -1,86 +0,0 @@ -/* - This file is part of TALER - (C) 2016 Inria - - TALER is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - TALER is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - TALER; see the file COPYING. If not, see - */ - -/** - * Show wallet logs. - * - * @author Florian Dold - */ - -import { - LogEntry, - getLogs, -} from "../../logging"; - -import * as React from "react"; -import * as ReactDOM from "react-dom"; - -interface LogViewProps { - log: LogEntry; -} - -class LogView extends React.Component { - render(): JSX.Element { - const e = this.props.log; - return ( -
-
    -
  • level: {e.level}
  • -
  • msg: {e.msg}
  • -
  • id: {e.id || "unknown"}
  • -
  • file: {e.source || "(unknown)"}
  • -
  • line: {e.line || "(unknown)"}
  • -
  • col: {e.col || "(unknown)"}
  • - {(e.detail ?
  • detail:
    {e.detail}
  • : [])} -
-
- ); - } -} - -interface LogsState { - logs: LogEntry[]|undefined; -} - -class Logs extends React.Component<{}, LogsState> { - constructor(props: {}) { - super({}); - this.update(); - this.state = {} as any; - } - - async update() { - const logs = await getLogs(); - this.setState({logs}); - } - - render(): JSX.Element { - const logs = this.state.logs; - if (!logs) { - return ...; - } - return ( -
- Logs: - {logs.map((e) => )} -
- ); - } -} - -document.addEventListener("DOMContentLoaded", () => { - ReactDOM.render(, document.getElementById("container")!); -}); diff --git a/src/webex/renderHtml.tsx b/src/webex/renderHtml.tsx index e2f821058..f2cccfba6 100644 --- a/src/webex/renderHtml.tsx +++ b/src/webex/renderHtml.tsx @@ -137,12 +137,12 @@ function AuditorDetailsView(props: {

); } - if (rci.exchangeInfo.auditors.length === 0) { + if ((rci.exchangeInfo.details?.auditors ?? []).length === 0) { return

The exchange is not audited by any auditors.

; } return (
- {rci.exchangeInfo.auditors.map(a => ( + {(rci.exchangeInfo.details?.auditors ?? []).map(a => (

Auditor {a.auditor_url}

@@ -231,7 +231,7 @@ function FeeDetailsView(props: {

Overview

- Public key: + Public key:

{i18n.str`Withdrawal fees:`} {withdrawFee} diff --git a/src/webex/wxApi.ts b/src/webex/wxApi.ts index 39c31ca51..a50672131 100644 --- a/src/webex/wxApi.ts +++ b/src/webex/wxApi.ts @@ -123,13 +123,6 @@ export function getCurrencies(): Promise { } -/** - * Get information about a specific currency. - */ -export function getCurrency(name: string): Promise { - return callBackend("currency-info", {name}); -} - /** * Get information about a specific exchange. @@ -225,12 +218,6 @@ export function submitPay(contractTermsHash: string, sessionId: string | undefin return callBackend("submit-pay", { contractTermsHash, sessionId }); } -/** - * Hash a contract. Throws if its not a valid contract. - */ -export function hashContract(contract: object): Promise { - return callBackend("hash-contract", { contract }); -} /** * Mark a reserve as confirmed. @@ -284,25 +271,6 @@ export function returnCoins(args: { amount: AmountJson, exchange: string, sender } -/** - * Record an error report and display it in a tabl. - * - * If sameTab is set, the error report will be opened in the current tab, - * otherwise in a new tab. - */ -export function logAndDisplayError(args: any): Promise { - return callBackend("log-and-display-error", args); -} - -/** - * Get an error report from the logging database for the - * given report UID. - */ -export function getReport(reportUid: string): Promise { - return callBackend("get-report", { reportUid }); -} - - /** * Look up a purchase in the wallet database from * the contract terms hash. diff --git a/src/webex/wxBackend.ts b/src/webex/wxBackend.ts index 16cd2a78c..f4decbc60 100644 --- a/src/webex/wxBackend.ts +++ b/src/webex/wxBackend.ts @@ -24,7 +24,6 @@ * Imports. */ import { BrowserHttpLib } from "../http"; -import * as logging from "../logging"; import { AmountJson } from "../amounts"; import { ConfirmReserveRequest, @@ -138,22 +137,6 @@ async function handleMessage( } return needsWallet().updateExchangeFromUrl(detail.baseUrl); } - case "currency-info": { - if (!detail.name) { - return Promise.resolve({ error: "name missing" }); - } - return needsWallet().getCurrencyRecord(detail.name); - } - case "hash-contract": { - if (!detail.contract) { - return Promise.resolve({ error: "contract missing" }); - } - return needsWallet() - .hashContract(detail.contract) - .then(hash => { - return hash; - }); - } case "reserve-creation-info": { if (!detail.baseUrl || typeof detail.baseUrl !== "string") { return Promise.resolve({ error: "bad url" }); @@ -243,20 +226,6 @@ async function handleMessage( }; return resp; } - case "log-and-display-error": - logging.storeReport(detail).then(reportUid => { - const url = chrome.extension.getURL( - `/src/webex/pages/error.html?reportUid=${reportUid}`, - ); - if (detail.sameTab && sender && sender.tab && sender.tab.id) { - chrome.tabs.update(detail.tabId, { url }); - } else { - chrome.tabs.create({ url }); - } - }); - return; - case "get-report": - return logging.getReport(detail.reportUid); case "get-purchase-details": { const contractTermsHash = detail.contractTermsHash; if (!contractTermsHash) { @@ -574,17 +543,6 @@ export async function wxMain() { chrome.runtime.reload(); }); - window.onerror = (m, source, lineno, colno, error) => { - logging.record( - "error", - "".concat(m as any, error as any), - undefined, - source || "(unknown)", - lineno || 0, - colno || 0, - ); - }; - chrome.tabs.query({}, tabs => { console.log("got tabs", tabs); for (const tab of tabs) { diff --git a/tsconfig.json b/tsconfig.json index e190e14b7..25087b600 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -53,7 +53,6 @@ "src/index.ts", "src/libtoolVersion-test.ts", "src/libtoolVersion.ts", - "src/logging.ts", "src/promiseUtils.ts", "src/query.ts", "src/talerTypes.ts", @@ -72,8 +71,6 @@ "src/webex/pages/add-auditor.tsx", "src/webex/pages/auditors.tsx", "src/webex/pages/benchmark.tsx", - "src/webex/pages/error.tsx", - "src/webex/pages/logs.tsx", "src/webex/pages/pay.tsx", "src/webex/pages/payback.tsx", "src/webex/pages/popup.tsx", diff --git a/webpack.config.js b/webpack.config.js index 096455887..ab893eaca 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -80,8 +80,6 @@ module.exports = function (env) { "pay": "./src/webex/pages/pay.tsx", "withdraw": "./src/webex/pages/withdraw.tsx", "welcome": "./src/webex/pages/welcome.tsx", - "error": "./src/webex/pages/error.tsx", - "logs": "./src/webex/pages/logs.tsx", "payback": "./src/webex/pages/payback.tsx", "popup": "./src/webex/pages/popup.tsx", "reset-required": "./src/webex/pages/reset-required.tsx", diff --git a/yarn.lock b/yarn.lock index 2e7ec95c9..dea3f786f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3378,10 +3378,10 @@ iconv-lite@0.4.24, iconv-lite@^0.4.4, iconv-lite@~0.4.13: dependencies: safer-buffer ">= 2.1.2 < 3" -idb-bridge@^0.0.10: - version "0.0.10" - resolved "https://registry.yarnpkg.com/idb-bridge/-/idb-bridge-0.0.10.tgz#69d59550dc722f6bf62cb98a4d4a2f2b9e66653c" - integrity sha512-AE8YipDGjnS+APSlupcPNCNslCAErRwfUgJ8aNZigmVIr3xzI0YPGWr6NC6UI6ofpQ1DczUlaKqA86m1R/COGQ== +idb-bridge@^0.0.11: + version "0.0.11" + resolved "https://registry.yarnpkg.com/idb-bridge/-/idb-bridge-0.0.11.tgz#ba2fbd24b7e6f7f4de8333ed12b0912e64dda308" + integrity sha512-fLlHce/WwT6eD3sc54gsfvM5fZqrhAPwBNH4uU/y6D0C1+0higH7OgC5/wploMhkmNYkQID3BMNZvSUBr0leSQ== ieee754@^1.1.4: version "1.1.13"

-

Failed to send payment

-

- Failed to send payment for {summary}{" "} - to merchant {report.contractTerms.merchant.name}. -

-

- You can retry the payment.{" "} - If this problem persists, please contact the mechant with the error details below. -

- -
-                  {JSON.stringify(report, null, " ")}
-                
-
-