From c6233094306cd264f8faa2041388dff01ff8cf01 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Thu, 21 Nov 2019 23:09:43 +0100 Subject: WIP: simplification and error handling --- package.json | 2 +- src/crypto/cryptoApi-test.ts | 23 +- src/crypto/cryptoImplementation.ts | 16 +- src/db.ts | 1 - src/dbTypes.ts | 171 ++--- src/headless/clk.ts | 2 +- src/headless/helpers.ts | 16 +- src/headless/integrationtest.ts | 1 + src/headless/taler-wallet-cli.ts | 380 +++++------ src/http.ts | 7 - src/query.ts | 78 ++- src/wallet.ts | 1243 ++++++++++++++++++++---------------- src/walletTypes.ts | 34 +- src/webex/messages.ts | 2 +- src/webex/pages/payback.tsx | 6 +- src/webex/pages/popup.tsx | 8 +- src/webex/renderHtml.tsx | 2 +- src/webex/wxBackend.ts | 2 +- tsconfig.json | 3 + yarn.lock | 8 +- 20 files changed, 1102 insertions(+), 903 deletions(-) diff --git a/package.json b/package.json index 980274e09..fa8cd1d02 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "@types/chrome": "^0.0.91", "@types/urijs": "^1.19.3", "axios": "^0.19.0", - "idb-bridge": "^0.0.11", + "idb-bridge": "^0.0.14", "qrcode-generator": "^1.4.3", "source-map-support": "^0.5.12", "urijs": "^1.18.10" diff --git a/src/crypto/cryptoApi-test.ts b/src/crypto/cryptoApi-test.ts index 39f46c5c3..d9d42081c 100644 --- a/src/crypto/cryptoApi-test.ts +++ b/src/crypto/cryptoApi-test.ts @@ -22,6 +22,7 @@ import { DenominationRecord, DenominationStatus, ReserveRecord, + ReserveRecordStatus, } from "../dbTypes"; import { CryptoApi } from "./cryptoApi"; @@ -86,18 +87,18 @@ test("precoin creation", async t => { const crypto = new CryptoApi(new NodeCryptoWorkerFactory()); const { priv, pub } = await crypto.createEddsaKeypair(); const r: ReserveRecord = { - created: 0, - current_amount: null, - exchange_base_url: "https://example.com/exchange", + created: { t_ms: 0 }, + currentAmount: null, + exchangeBaseUrl: "https://example.com/exchange", hasPayback: false, - precoin_amount: { currency: "PUDOS", value: 0, fraction: 0 }, - requested_amount: { currency: "PUDOS", value: 0, fraction: 0 }, - reserve_priv: priv, - reserve_pub: pub, - timestamp_confirmed: 0, - timestamp_depleted: 0, - timestamp_reserve_info_posted: 0, - exchangeWire: "payto://foo" + precoinAmount: { currency: "PUDOS", value: 0, fraction: 0 }, + requestedAmount: { currency: "PUDOS", value: 0, fraction: 0 }, + reservePriv: priv, + reservePub: pub, + timestampConfirmed: undefined, + timestampReserveInfoPosted: undefined, + exchangeWire: "payto://foo", + reserveStatus: ReserveRecordStatus.UNCONFIRMED, }; const precoin = await crypto.createPreCoin(denomValid1, r); diff --git a/src/crypto/cryptoImplementation.ts b/src/crypto/cryptoImplementation.ts index d50d40027..7dd019c18 100644 --- a/src/crypto/cryptoImplementation.ts +++ b/src/crypto/cryptoImplementation.ts @@ -45,6 +45,7 @@ import * as native from "./emscInterface"; import { AmountJson } from "../amounts"; import * as Amounts from "../amounts"; import * as timer from "../timer"; +import { getRandomBytes, encodeCrock } from "./nativeCrypto"; export class CryptoImplementation { static enableTracing: boolean = false; @@ -60,9 +61,9 @@ export class CryptoImplementation { reserve: ReserveRecord, ): PreCoinRecord { const reservePriv = new native.EddsaPrivateKey(this.emsc); - reservePriv.loadCrock(reserve.reserve_priv); + reservePriv.loadCrock(reserve.reservePriv); const reservePub = new native.EddsaPublicKey(this.emsc); - reservePub.loadCrock(reserve.reserve_pub); + reservePub.loadCrock(reserve.reservePub); const denomPub = native.RsaPublicKey.fromCrock(this.emsc, denom.denomPub); const coinPriv = native.EddsaPrivateKey.create(this.emsc); const coinPub = coinPriv.getPublicKey(); @@ -103,7 +104,7 @@ export class CryptoImplementation { coinValue: denom.value, denomPub: denomPub.toCrock(), denomPubHash: denomPubHash.toCrock(), - exchangeBaseUrl: reserve.exchange_base_url, + exchangeBaseUrl: reserve.exchangeBaseUrl, isFromTip: false, reservePub: reservePub.toCrock(), withdrawSig: sig.toCrock(), @@ -199,14 +200,14 @@ export class CryptoImplementation { isValidWireFee(type: string, wf: WireFee, masterPub: string): boolean { const p = new native.MasterWireFeePS(this.emsc, { closing_fee: new native.Amount(this.emsc, wf.closingFee).toNbo(), - end_date: native.AbsoluteTimeNbo.fromStampSeconds(this.emsc, wf.endStamp), + end_date: native.AbsoluteTimeNbo.fromStampSeconds(this.emsc, (wf.endStamp.t_ms / 1000)), h_wire_method: native.ByteArray.fromStringWithNull( this.emsc, type, ).hash(), start_date: native.AbsoluteTimeNbo.fromStampSeconds( this.emsc, - wf.startStamp, + Math.floor(wf.startStamp.t_ms / 1000), ), wire_fee: new native.Amount(this.emsc, wf.wireFee).toNbo(), }); @@ -354,7 +355,7 @@ export class CryptoImplementation { const newAmount = new native.Amount(this.emsc, cd.coin.currentAmount); newAmount.sub(coinSpend); cd.coin.currentAmount = newAmount.toJson(); - cd.coin.status = CoinStatus.PurchasePending; + cd.coin.status = CoinStatus.Dirty; const d = new native.DepositRequestPS(this.emsc, { amount_with_fee: coinSpend.toNbo(), @@ -505,7 +506,10 @@ export class CryptoImplementation { valueOutput = Amounts.add(valueOutput, denom.value).amount; } + const refreshSessionId = encodeCrock(getRandomBytes(32)); + const refreshSession: RefreshSessionRecord = { + refreshSessionId, confirmSig, exchangeBaseUrl, finished: false, diff --git a/src/db.ts b/src/db.ts index 00eac4320..e317b0aaf 100644 --- a/src/db.ts +++ b/src/db.ts @@ -12,7 +12,6 @@ export function openTalerDb( onVersionChange: () => void, onUpgradeUnsupported: (oldVersion: number, newVersion: number) => void, ): Promise { - console.log("in openTalerDb"); return new Promise((resolve, reject) => { const req = idbFactory.open(DB_NAME, WALLET_DB_VERSION); req.onerror = e => { diff --git a/src/dbTypes.ts b/src/dbTypes.ts index 0d54069ec..22d98ffac 100644 --- a/src/dbTypes.ts +++ b/src/dbTypes.ts @@ -46,6 +46,36 @@ import { Timestamp, OperationError } from "./walletTypes"; */ export const WALLET_DB_VERSION = 27; +export enum ReserveRecordStatus { + /** + * Waiting for manual confirmation. + */ + UNCONFIRMED = "unconfirmed", + + /** + * Reserve must be registered with the bank. + */ + REGISTERING_BANK = "registering-bank", + + /** + * Querying reserve status with the exchange. + */ + QUERYING_STATUS = "querying-status", + + /** + * Status is queried, the wallet must now select coins + * and start withdrawing. + */ + WITHDRAWING = "withdrawing", + + /** + * The corresponding withdraw record has been created. + * No further processing is done, unless explicitly requested + * by the user. + */ + DORMANT = "dormant", +} + /** * A reserve record as stored in the wallet's database. */ @@ -53,28 +83,22 @@ export interface ReserveRecord { /** * The reserve public key. */ - reserve_pub: string; + reservePub: string; /** * The reserve private key. */ - reserve_priv: string; + reservePriv: string; /** * The exchange base URL. */ - exchange_base_url: string; + exchangeBaseUrl: string; /** * Time when the reserve was created. */ - created: number; - - /** - * Time when the reserve was depleted. - * Set to 0 if not depleted yet. - */ - timestamp_depleted: number; + created: Timestamp; /** * Time when the information about this reserve was posted to the bank. @@ -83,32 +107,32 @@ export interface ReserveRecord { * * Set to 0 if that hasn't happened yet. */ - timestamp_reserve_info_posted: number; + timestampReserveInfoPosted: Timestamp | undefined; /** * Time when the reserve was confirmed. * * Set to 0 if not confirmed yet. */ - timestamp_confirmed: number; + timestampConfirmed: Timestamp | undefined; /** * Current amount left in the reserve */ - current_amount: AmountJson | null; + currentAmount: AmountJson | null; /** * Amount requested when the reserve was created. * When a reserve is re-used (rare!) the current_amount can * be higher than the requested_amount */ - requested_amount: AmountJson; + requestedAmount: AmountJson; /** * What's the current amount that sits * in precoins? */ - precoin_amount: AmountJson; + precoinAmount: AmountJson; /** * We got some payback to this reserve. We'll cease to automatically @@ -129,6 +153,10 @@ export interface ReserveRecord { exchangeWire: string; bankWithdrawStatusUrl?: string; + + reserveStatus: ReserveRecordStatus; + + lastError?: OperationError; } /** @@ -341,9 +369,9 @@ export interface ExchangeDetails { } export enum ExchangeUpdateStatus { - NONE = "none", FETCH_KEYS = "fetch_keys", FETCH_WIRE = "fetch_wire", + FINISHED = "finished", } export interface ExchangeBankAccount { @@ -374,13 +402,18 @@ export interface ExchangeRecord { */ wireInfo: ExchangeWireInfo | undefined; + /** + * When was the exchange added to the wallet? + */ + timestampAdded: Timestamp; + /** * Time when the update to the exchange has been started or * undefined if no update is in progress. */ updateStarted: Timestamp | undefined; - updateStatus: ExchangeUpdateStatus; + updateReason?: "initial" | "forced"; lastError?: OperationError; } @@ -436,31 +469,15 @@ export enum CoinStatus { /** * Withdrawn and never shown to anybody. */ - Fresh, - /** - * Currently planned to be sent to a merchant for a purchase. - */ - PurchasePending, + Fresh = "fresh", /** * Used for a completed transaction and now dirty. */ - Dirty, + Dirty = "dirty", /** - * A coin that was refreshed. + * A coin that has been spent and refreshed. */ - Refreshed, - /** - * Coin marked to be paid back, but payback not finished. - */ - PaybackPending, - /** - * Coin fully paid back. - */ - PaybackDone, - /** - * Coin was dirty but can't be refreshed. - */ - Useless, + Dormant = "dormant", } /** @@ -569,7 +586,7 @@ export class ProposalDownloadRecord { * was created. */ @Checkable.Number() - timestamp: number; + timestamp: Timestamp; /** * Private key for the nonce. @@ -658,7 +675,7 @@ export interface TipRecord { */ nextUrl?: string; - timestamp: number; + timestamp: Timestamp; pickupUrl: string; } @@ -735,9 +752,9 @@ export interface RefreshSessionRecord { finished: boolean; /** - * Record ID when retrieved from the DB. + * A 32-byte base32-crockford encoded random identifier. */ - id?: number; + refreshSessionId: string; } /** @@ -771,12 +788,12 @@ export interface WireFee { /** * Start date of the fee. */ - startStamp: number; + startStamp: Timestamp; /** * End date of the fee. */ - endStamp: number; + endStamp: Timestamp; /** * Signature made by the exchange master key. @@ -830,14 +847,13 @@ export interface PurchaseRecord { * When was the purchase made? * Refers to the time that the user accepted. */ - timestamp: number; + timestamp: Timestamp; /** * When was the last refund made? * Set to 0 if no refund was made on the purchase. */ - timestamp_refund: number; - + timestamp_refund: Timestamp | undefined; /** * Last session signature that we submitted to /pay (if any). @@ -917,7 +933,6 @@ export interface CoinsReturnRecord { wire: any; } - export interface WithdrawalRecord { /** * Reserve that we're withdrawing from. @@ -928,18 +943,22 @@ export interface WithdrawalRecord { * When was the withdrawal operation started started? * Timestamp in milliseconds. */ - startTimestamp: number; + startTimestamp: Timestamp; /** * When was the withdrawal operation completed? */ - finishTimestamp?: number; + finishTimestamp?: Timestamp; /** * Amount that is being withdrawn with this operation. * This does not include fees. */ withdrawalAmount: string; + + numCoinsTotal: number; + + numCoinsWithdrawn: number; } /* tslint:disable:completed-docs */ @@ -983,11 +1002,6 @@ export namespace Stores { "urlIndex", "url", ); - timestampIndex = new Index( - this, - "timestampIndex", - "timestamp", - ); } class PurchasesStore extends Store { @@ -1005,11 +1019,6 @@ export namespace Stores { "orderIdIndex", "contractTerms.order_id", ); - timestampIndex = new Index( - this, - "timestampIndex", - "timestamp", - ); } class DenominationsStore extends Store { @@ -1051,23 +1060,8 @@ export namespace Stores { class ReservesStore extends Store { constructor() { - super("reserves", { keyPath: "reserve_pub" }); + super("reserves", { keyPath: "reservePub" }); } - timestampCreatedIndex = new Index( - this, - "timestampCreatedIndex", - "created", - ); - timestampConfirmedIndex = new Index( - this, - "timestampConfirmedIndex", - "timestamp_confirmed", - ); - timestampDepletedIndex = new Index( - this, - "timestampDepletedIndex", - "timestamp_depleted", - ); } class TipsStore extends Store { @@ -1092,8 +1086,26 @@ export namespace Stores { class WithdrawalsStore extends Store { constructor() { - super("withdrawals", { keyPath: "id", autoIncrement: true }) + super("withdrawals", { keyPath: "id", autoIncrement: true }); } + byReservePub = new Index( + this, + "withdrawalsReservePubIndex", + "reservePub", + ); + } + + class PreCoinsStore extends Store { + constructor() { + super("precoins", { + keyPath: "coinPub", + }); + } + byReservePub = new Index( + this, + "precoinsReservePubIndex", + "reservePub", + ); } export const coins = new CoinsStore(); @@ -1104,13 +1116,10 @@ export namespace Stores { export const currencies = new CurrenciesStore(); export const denominations = new DenominationsStore(); export const exchanges = new ExchangeStore(); - export const precoins = new Store("precoins", { - keyPath: "coinPub", - }); + export const precoins = new PreCoinsStore(); export const proposals = new ProposalsStore(); export const refresh = new Store("refresh", { - keyPath: "id", - autoIncrement: true, + keyPath: "refreshSessionId", }); export const reserves = new ReservesStore(); export const purchases = new PurchasesStore(); diff --git a/src/headless/clk.ts b/src/headless/clk.ts index f66d609e8..51ee119c9 100644 --- a/src/headless/clk.ts +++ b/src/headless/clk.ts @@ -440,7 +440,7 @@ export class CommandGroup { if (option.isFlag == false && option.required == true) { if (!foundOptions[option.name]) { if (option.args.default !== undefined) { - parsedArgs[this.argKey] = option.args.default; + myArgs[option.name] = option.args.default; } else { const name = option.flagspec.join(",") console.error(`error: missing option '${name}'`); diff --git a/src/headless/helpers.ts b/src/headless/helpers.ts index 49881d469..5e06a2f25 100644 --- a/src/headless/helpers.ts +++ b/src/headless/helpers.ts @@ -21,7 +21,7 @@ /** * Imports. */ -import { Wallet } from "../wallet"; +import { Wallet, OperationFailedAndReportedError } from "../wallet"; import { Notifier, Badge } from "../walletTypes"; import { MemoryBackend, BridgeIDBFactory, shimIndexedDB } from "idb-bridge"; import { SynchronousCryptoWorkerFactory } from "../crypto/synchronousWorker"; @@ -139,18 +139,16 @@ export async function getDefaultNodeWallet( const storagePath = args.persistentStoragePath; if (storagePath) { - console.log(`using storage path ${storagePath}`); - try { const dbContentStr: string = fs.readFileSync(storagePath, { encoding: "utf-8" }); const dbContent = JSON.parse(dbContentStr); myBackend.importDump(dbContent); - console.log("imported wallet"); } catch (e) { - console.log("could not read wallet file"); + console.error("could not read wallet file"); } myBackend.afterCommitCallback = async () => { + console.log("DATABASE COMMITTED"); // Allow caller to stop persisting the wallet. if (args.persistentStoragePath === undefined) { return; @@ -190,8 +188,6 @@ export async function getDefaultNodeWallet( myUnsupportedUpgrade, ); - console.log("opened db"); - return new Wallet( myDb, myHttpLib, @@ -214,6 +210,8 @@ export async function withdrawTestBalance( exchangeWire: "payto://unknown", }); + const reservePub = reserveResponse.reservePub; + const bank = new Bank(bankBaseUrl); const bankUser = await bank.registerRandomUser(); @@ -228,11 +226,11 @@ export async function withdrawTestBalance( await bank.createReserve( bankUser, amount, - reserveResponse.reservePub, + reservePub, exchangePaytoUri, ); await myWallet.confirmReserve({ reservePub: reserveResponse.reservePub }); - await myWallet.processReserve(reserveResponse.reservePub); + await myWallet.runUntilReserveDepleted(reservePub); } diff --git a/src/headless/integrationtest.ts b/src/headless/integrationtest.ts index 6b3286904..6f2139c9d 100644 --- a/src/headless/integrationtest.ts +++ b/src/headless/integrationtest.ts @@ -31,6 +31,7 @@ export async function runIntegrationTest(args: { amountToWithdraw: string; amountToSpend: string; }) { + console.log("running test with", args); const myWallet = await getDefaultNodeWallet(); await withdrawTestBalance(myWallet, args.amountToWithdraw, args.bankBaseUrl, args.exchangeBaseUrl); diff --git a/src/headless/taler-wallet-cli.ts b/src/headless/taler-wallet-cli.ts index 06235d0b4..0a6780808 100644 --- a/src/headless/taler-wallet-cli.ts +++ b/src/headless/taler-wallet-cli.ts @@ -18,9 +18,14 @@ import os = require("os"); import { getDefaultNodeWallet, withdrawTestBalance } from "./helpers"; import { MerchantBackendConnection } from "./merchant"; import { runIntegrationTest } from "./integrationtest"; -import { Wallet } from "../wallet"; +import { Wallet, OperationFailedAndReportedError } from "../wallet"; import qrcodeGenerator = require("qrcode-generator"); import * as clk from "./clk"; +import { BridgeIDBFactory, MemoryBackend } from "idb-bridge"; +import { Logger } from "../logging"; +import * as Amounts from "../amounts"; + +const logger = new Logger("taler-wallet-cli.ts"); const walletDbPath = os.homedir + "/" + ".talerwalletdb.json"; @@ -82,6 +87,7 @@ function applyVerbose(verbose: boolean) { if (verbose) { console.log("enabled verbose logging"); Wallet.enableTracing = true; + BridgeIDBFactory.enableTracing = true; } } @@ -103,62 +109,32 @@ async function withWallet( walletCliArgs: WalletCliArgsType, f: (w: Wallet) => Promise, ): Promise { - applyVerbose(walletCliArgs.wallet.verbose); const wallet = await getDefaultNodeWallet({ persistentStoragePath: walletDbPath, }); + applyVerbose(walletCliArgs.wallet.verbose); try { await wallet.fillDefaults(); const ret = await f(wallet); return ret; } catch (e) { - console.error("caught exception:", e); + if (e instanceof OperationFailedAndReportedError) { + console.error("Operation failed: " + e.message); + console.log("Hint: check pending operations for details."); + } else { + 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) - .requiredOption("summary", ["-s", "--summary"], clk.STRING, { - default: "Test Payment", - }) - .action(async args => { - const cmdArgs = args.testPayCmd; - console.log("creating order"); - const merchantBackend = new MerchantBackendConnection( - "https://backend.test.taler.net/", - "sandbox", - ); - const orderResp = await merchantBackend.createOrder( - cmdArgs.amount, - cmdArgs.summary, - "", - ); - console.log("created new order with order ID", orderResp.orderId); - const checkPayResp = await merchantBackend.checkPayment(orderResp.orderId); - const talerPayUri = checkPayResp.taler_pay_uri; - if (!talerPayUri) { - console.error("fatal: no taler pay URI received from backend"); - process.exit(1); - return; - } - console.log("taler pay URI:", talerPayUri); - - const wallet = await getDefaultNodeWallet({ - persistentStoragePath: walletDbPath, - }); - - await doPay(wallet, talerPayUri, { alwaysYes: true }); - }); - walletCli .subcommand("", "balance", { help: "Show wallet balance." }) .action(async args => { console.log("balance command called"); - withWallet(args, async (wallet) => { + await withWallet(args, async wallet => { const balance = await wallet.getBalances(); console.log(JSON.stringify(balance, undefined, 2)); }); @@ -166,12 +142,12 @@ walletCli walletCli .subcommand("", "history", { help: "Show wallet event history." }) - .requiredOption("from", ["--from"], clk.STRING) - .requiredOption("to", ["--to"], clk.STRING) - .requiredOption("limit", ["--limit"], clk.STRING) - .requiredOption("contEvt", ["--continue-with"], clk.STRING) + .maybeOption("from", ["--from"], clk.STRING) + .maybeOption("to", ["--to"], clk.STRING) + .maybeOption("limit", ["--limit"], clk.STRING) + .maybeOption("contEvt", ["--continue-with"], clk.STRING) .action(async args => { - withWallet(args, async (wallet) => { + await withWallet(args, async wallet => { const history = await wallet.getHistory(); console.log(JSON.stringify(history, undefined, 2)); }); @@ -180,7 +156,7 @@ walletCli walletCli .subcommand("", "pending", { help: "Show pending operations." }) .action(async args => { - withWallet(args, async (wallet) => { + await withWallet(args, async wallet => { const pending = await wallet.getPendingOperations(); console.log(JSON.stringify(pending, undefined, 2)); }); @@ -194,25 +170,129 @@ async function asyncSleep(milliSeconds: number): Promise { walletCli .subcommand("runPendingOpt", "run-pending", { - help: "Run pending operations." + help: "Run pending operations.", }) - .action(async (args) => { - withWallet(args, async (wallet) => { - await wallet.processPending(); + .action(async args => { + await withWallet(args, async wallet => { + await wallet.runPending(); }); }); walletCli - .subcommand("testMerchantQrcodeCmd", "test-merchant-qrcode") - .requiredOption("amount", ["-a", "--amount"], clk.STRING, { - default: "TESTKUDOS:1", + .subcommand("handleUri", "handle-uri", { + help: "Handle a taler:// URI.", }) + .requiredArgument("uri", clk.STRING) + .flag("autoYes", ["-y", "--yes"]) + .action(async args => { + await withWallet(args, async wallet => { + const uri: string = args.handleUri.uri; + if (uri.startsWith("taler://pay/")) { + await doPay(wallet, uri, { alwaysYes: args.handleUri.autoYes }); + } else if (uri.startsWith("taler://tip/")) { + const res = await wallet.getTipStatus(uri); + console.log("tip status", res); + await wallet.acceptTip(uri); + } else if (uri.startsWith("taler://refund/")) { + await wallet.applyRefund(uri); + } else if (uri.startsWith("taler://withdraw/")) { + const withdrawInfo = await wallet.getWithdrawalInfo(uri); + const selectedExchange = withdrawInfo.suggestedExchange; + if (!selectedExchange) { + console.error("no suggested exchange!"); + process.exit(1); + return; + } + const { confirmTransferUrl } = await wallet.acceptWithdrawal( + uri, + selectedExchange, + ); + if (confirmTransferUrl) { + console.log("please confirm the transfer at", confirmTransferUrl); + } + } else { + console.error("unrecognized URI"); + } + }); + }); + +const exchangesCli = walletCli.subcommand("exchangesCmd", "exchanges", { + help: "Manage exchanges.", +}); + +exchangesCli + .subcommand("exchangesListCmd", "list", { + help: "List known exchanges.", + }) + .action(async args => { + console.log("Listing exchanges ..."); + await 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.", + }) + .flag("force", ["-f", "--force"]) + .action(async args => { + await withWallet(args, async wallet => { + const res = await wallet.updateExchangeFromUrl( + args.exchangesUpdateCmd.url, + args.exchangesUpdateCmd.force, + ); + }); + }); + +const advancedCli = walletCli.subcommand("advancedArgs", "advanced", { + help: + "Subcommands for advanced operations (only use if you know what you're doing!).", +}); + +advancedCli + .subcommand("refresh", "force-refresh", { + help: "Force a refresh on a coin.", + }) + .requiredArgument("coinPub", clk.STRING) + .action(async args => { + await withWallet(args, async wallet => { + await wallet.refresh(args.refresh.coinPub, true); + }); + }); + +advancedCli + .subcommand("coins", "list-coins", { + help: "List coins.", + }) + .action(async args => { + await withWallet(args, async wallet => { + const coins = await wallet.getCoins(); + for (const coin of coins) { + console.log(`coin ${coin.coinPub}`); + console.log(` status ${coin.status}`); + console.log(` exchange ${coin.exchangeBaseUrl}`); + console.log(` remaining amount ${Amounts.toString(coin.currentAmount)}`); + } + }); + }); + +const testCli = walletCli.subcommand("testingArgs", "testing", { + help: "Subcommands for testing GNU Taler deployments.", +}); + +testCli + .subcommand("testPayCmd", "test-pay", { help: "create contract and pay" }) + .requiredOption("amount", ["-a", "--amount"], clk.STRING) .requiredOption("summary", ["-s", "--summary"], clk.STRING, { default: "Test Payment", }) .action(async args => { - const cmdArgs = args.testMerchantQrcodeCmd; - applyVerbose(args.wallet.verbose); + const cmdArgs = args.testPayCmd; console.log("creating order"); const merchantBackend = new MerchantBackendConnection( "https://backend.test.taler.net/", @@ -225,7 +305,6 @@ walletCli ); console.log("created new order with order ID", orderResp.orderId); const checkPayResp = await merchantBackend.checkPayment(orderResp.orderId); - const qrcode = qrcodeGenerator(0, "M"); const talerPayUri = checkPayResp.taler_pay_uri; if (!talerPayUri) { console.error("fatal: no taler pay URI received from backend"); @@ -233,23 +312,13 @@ walletCli return; } console.log("taler pay URI:", talerPayUri); - qrcode.addData(talerPayUri); - qrcode.make(); - console.log(qrcode.createASCII()); - console.log("waiting for payment ..."); - while (1) { - await asyncSleep(500); - const checkPayResp2 = await merchantBackend.checkPayment( - orderResp.orderId, - ); - if (checkPayResp2.paid) { - console.log("payment successfully received!"); - break; - } - } + await withWallet(args, async (wallet) => { + await doPay(wallet, talerPayUri, { alwaysYes: true }); + }); }); -walletCli + +testCli .subcommand("integrationtestCmd", "integrationtest", { help: "Run integration test with bank, exchange and merchant.", }) @@ -265,13 +334,14 @@ walletCli .requiredOption("bank", ["-b", "--bank"], clk.STRING, { default: "https://bank.test.taler.net/", }) - .requiredOption("withdrawAmount", ["-b", "--bank"], clk.STRING, { + .requiredOption("withdrawAmount", ["-a", "--amount"], clk.STRING, { default: "TESTKUDOS:10", }) .requiredOption("spendAmount", ["-s", "--spend-amount"], clk.STRING, { default: "TESTKUDOS:4", }) .action(async args => { + console.log("parsed args", args); applyVerbose(args.wallet.verbose); let cmdObj = args.integrationtestCmd; @@ -295,128 +365,61 @@ walletCli } }); -walletCli - .subcommand("withdrawUriCmd", "withdraw-uri") - .requiredArgument("withdrawUri", clk.STRING) +testCli + .subcommand("testMerchantQrcodeCmd", "test-merchant-qrcode") + .requiredOption("amount", ["-a", "--amount"], clk.STRING, { + default: "TESTKUDOS:1", + }) + .requiredOption("summary", ["-s", "--summary"], clk.STRING, { + default: "Test Payment", + }) .action(async args => { + const cmdArgs = args.testMerchantQrcodeCmd; applyVerbose(args.wallet.verbose); - const cmdArgs = args.withdrawUriCmd; - const withdrawUrl = cmdArgs.withdrawUri; - console.log("withdrawing", withdrawUrl); - const wallet = await getDefaultNodeWallet({ - persistentStoragePath: walletDbPath, - }); - - const withdrawInfo = await wallet.getWithdrawalInfo(withdrawUrl); - - console.log("withdraw info", withdrawInfo); - - const selectedExchange = withdrawInfo.suggestedExchange; - if (!selectedExchange) { - console.error("no suggested exchange!"); + console.log("creating order"); + const merchantBackend = new MerchantBackendConnection( + "https://backend.test.taler.net/", + "sandbox", + ); + const orderResp = await merchantBackend.createOrder( + cmdArgs.amount, + cmdArgs.summary, + "", + ); + console.log("created new order with order ID", orderResp.orderId); + const checkPayResp = await merchantBackend.checkPayment(orderResp.orderId); + const qrcode = qrcodeGenerator(0, "M"); + const talerPayUri = checkPayResp.taler_pay_uri; + if (!talerPayUri) { + console.error("fatal: no taler pay URI received from backend"); process.exit(1); return; } - - const { reservePub, confirmTransferUrl } = await wallet.acceptWithdrawal( - withdrawUrl, - selectedExchange, - ); - - if (confirmTransferUrl) { - console.log("please confirm the transfer at", confirmTransferUrl); + console.log("taler pay URI:", talerPayUri); + qrcode.addData(talerPayUri); + qrcode.make(); + console.log(qrcode.createASCII()); + console.log("waiting for payment ..."); + while (1) { + await asyncSleep(500); + const checkPayResp2 = await merchantBackend.checkPayment( + orderResp.orderId, + ); + if (checkPayResp2.paid) { + console.log("payment successfully received!"); + break; + } } - - await wallet.processReserve(reservePub); - - console.log("finished withdrawing"); - - wallet.stop(); - }); - -walletCli - .subcommand("tipUriCmd", "tip-uri") - .requiredArgument("uri", clk.STRING) - .action(async args => { - applyVerbose(args.wallet.verbose); - const tipUri = args.tipUriCmd.uri; - console.log("getting tip", tipUri); - const wallet = await getDefaultNodeWallet({ - persistentStoragePath: walletDbPath, - }); - const res = await wallet.getTipStatus(tipUri); - console.log("tip status", res); - await wallet.acceptTip(tipUri); - wallet.stop(); }); -walletCli - .subcommand("refundUriCmd", "refund-uri") - .requiredArgument("uri", clk.STRING) - .action(async args => { - applyVerbose(args.wallet.verbose); - const refundUri = args.refundUriCmd.uri; - console.log("getting refund", refundUri); - const wallet = await getDefaultNodeWallet({ - persistentStoragePath: walletDbPath, - }); - await wallet.applyRefund(refundUri); - wallet.stop(); - }); - -const exchangesCli = walletCli.subcommand("exchangesCmd", "exchanges", { - help: "Manage exchanges.", -}); - -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") - .requiredArgument("url", clk.STRING) - .flag("autoYes", ["-y", "--yes"]) - .action(async args => { - applyVerbose(args.wallet.verbose); - const payUrl = args.payUriCmd.url; - console.log("paying for", payUrl); - const wallet = await getDefaultNodeWallet({ - persistentStoragePath: walletDbPath, - }); - - await doPay(wallet, payUrl, { alwaysYes: args.payUriCmd.autoYes }); - wallet.stop(); - }); - -const testCli = walletCli.subcommand("testingArgs", "testing", { - help: "Subcommands for testing GNU Taler deployments.", -}); - testCli .subcommand("withdrawArgs", "withdraw", { help: "Withdraw from a test bank (must support test registrations).", }) + .requiredOption("amount", ["-a", "--amount"], clk.STRING, { + default: "TESTKUDOS:10", + help: "Amount to withdraw.", + }) .requiredOption("exchange", ["-e", "--exchange"], clk.STRING, { default: "https://exchange.test.taler.net/", help: "Exchange base URL.", @@ -426,14 +429,15 @@ testCli help: "Bank base URL", }) .action(async args => { - applyVerbose(args.wallet.verbose); - console.log("balance command called"); - const wallet = await getDefaultNodeWallet({ - persistentStoragePath: walletDbPath, + await withWallet(args, async wallet => { + await withdrawTestBalance( + wallet, + args.withdrawArgs.amount, + args.withdrawArgs.bank, + args.withdrawArgs.exchange, + ); + logger.info("Withdraw done"); }); - console.log("got wallet"); - const balance = await wallet.getBalances(); - console.log(JSON.stringify(balance, undefined, 2)); }); walletCli.run(); diff --git a/src/http.ts b/src/http.ts index 8c1f772d8..a2bfab279 100644 --- a/src/http.ts +++ b/src/http.ts @@ -107,10 +107,3 @@ export class BrowserHttpLib implements HttpRequestLibrary { return this.req("post", url, { req: form }); } } - -/** - * Exception thrown on request errors. - */ -export class RequestException { - constructor(public detail: any) {} -} diff --git a/src/query.ts b/src/query.ts index f510da55d..5726bcaa6 100644 --- a/src/query.ts +++ b/src/query.ts @@ -1,5 +1,3 @@ -import { openPromise } from "./promiseUtils"; - /* This file is part of TALER (C) 2016 GNUnet e.V. @@ -22,6 +20,12 @@ import { openPromise } from "./promiseUtils"; * @author Florian Dold */ +/** + * Imports. + */ +import { openPromise } from "./promiseUtils"; + + /** * Result of an inner join. */ @@ -63,27 +67,48 @@ export interface IndexOptions { } function requestToPromise(req: IDBRequest): Promise { + const stack = Error("Failed request was started here.") return new Promise((resolve, reject) => { req.onsuccess = () => { resolve(req.result); }; req.onerror = () => { + console.log("error in DB request", req.error); reject(req.error); + console.log("Request failed:", stack); }; }); } -export function oneShotGet( +function transactionToPromise(tx: IDBTransaction): Promise { + const stack = Error("Failed transaction was started here."); + return new Promise((resolve, reject) => { + tx.onabort = () => { + reject(TransactionAbort); + }; + tx.oncomplete = () => { + resolve(); + }; + tx.onerror = () => { + console.error("Transaction failed:", stack); + reject(tx.error); + }; + }); +} + +export async 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); + const v = await requestToPromise(req) + await transactionToPromise(tx); + return v; } -export function oneShotGetIndexed( +export async function oneShotGetIndexed( db: IDBDatabase, index: Index, key: any, @@ -93,10 +118,12 @@ export function oneShotGetIndexed( .objectStore(index.storeName) .index(index.indexName) .get(key); - return requestToPromise(req); + const v = await requestToPromise(req); + await transactionToPromise(tx); + return v; } -export function oneShotPut( +export async function oneShotPut( db: IDBDatabase, store: Store, value: T, @@ -104,7 +131,9 @@ export function oneShotPut( ): Promise { const tx = db.transaction([store.name], "readwrite"); const req = tx.objectStore(store.name).put(value, key); - return requestToPromise(req); + const v = await requestToPromise(req); + await transactionToPromise(tx); + return v; } function applyMutation( @@ -115,7 +144,7 @@ function applyMutation( req.onsuccess = () => { const cursor = req.result; if (cursor) { - const val = cursor.value(); + const val = cursor.value; const modVal = f(val); if (modVal !== undefined && modVal !== null) { const req2: IDBRequest = cursor.update(modVal); @@ -138,7 +167,7 @@ function applyMutation( }); } -export function oneShotMutate( +export async function oneShotMutate( db: IDBDatabase, store: Store, key: any, @@ -146,7 +175,8 @@ export function oneShotMutate( ): Promise { const tx = db.transaction([store.name], "readwrite"); const req = tx.objectStore(store.name).openCursor(key); - return applyMutation(req, f); + await applyMutation(req, f); + await transactionToPromise(tx); } type CursorResult = CursorEmptyResult | CursorValueResult; @@ -326,15 +356,12 @@ export function runWithWriteTransaction( stores: Store[], f: (t: TransactionHandle) => Promise, ): Promise { + const stack = Error("Failed transaction was started here."); 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 @@ -350,15 +377,30 @@ export function runWithWriteTransaction( } resolve(funResult); }; + tx.onerror = () => { + console.error("error in transaction"); + }; tx.onabort = () => { - console.error("aborted transaction"); - reject(AbortTransaction); + if (tx.error) { + console.error("Transaction aborted with error:", tx.error); + } else { + console.log("Trasaction aborted (no error)"); + } + reject(TransactionAbort); }; const th = new TransactionHandle(tx); const resP = f(th); resP.then(result => { gotFunResult = true; funResult = result; + }).catch((e) => { + if (e == TransactionAbort) { + console.info("aborting transaction"); + } else { + tx.abort(); + console.error("Transaction failed:", e); + console.error(stack); + } }); }); } @@ -401,4 +443,4 @@ export class Index { /** * Exception that should be thrown by client code to abort a transaction. */ -export const AbortTransaction = Symbol("abort_transaction"); +export const TransactionAbort = Symbol("transaction_abort"); diff --git a/src/wallet.ts b/src/wallet.ts index 71e058fd9..58bb6b8c3 100644 --- a/src/wallet.ts +++ b/src/wallet.ts @@ -31,10 +31,10 @@ import { strcmp, extractTalerStamp, } from "./helpers"; -import { HttpRequestLibrary, RequestException } from "./http"; +import { HttpRequestLibrary } from "./http"; import * as LibtoolVersion from "./libtoolVersion"; import { - AbortTransaction, + TransactionAbort, oneShotPut, oneShotGet, runWithWriteTransaction, @@ -43,7 +43,6 @@ import { oneShotGetIndexed, oneShotMutate, } from "./query"; -import { TimerGroup } from "./timer"; import { AmountJson } from "./amounts"; import * as Amounts from "./amounts"; @@ -70,6 +69,7 @@ import { WithdrawalRecord, ExchangeDetails, ExchangeUpdateStatus, + ReserveRecordStatus, } from "./dbTypes"; import { Auditor, @@ -99,7 +99,7 @@ import { ConfirmReserveRequest, CreateReserveRequest, CreateReserveResponse, - HistoryRecord, + HistoryEvent, NextUrlResult, Notifier, PayCoinInfo, @@ -119,8 +119,8 @@ import { HistoryQuery, getTimestampNow, OperationError, + Timestamp, } from "./walletTypes"; -import { openPromise } from "./promiseUtils"; import { parsePayUri, parseWithdrawUri, @@ -128,6 +128,7 @@ import { parseRefundUri, } from "./taleruri"; import { isFirefox } from "./webex/compat"; +import { Logger } from "./logging"; interface SpeculativePayData { payCoinInfo: PayCoinInfo; @@ -343,22 +344,24 @@ interface CoinsForPaymentArgs { paymentAmount: AmountJson; wireFeeAmortization: number; wireFeeLimit: AmountJson; - wireFeeTime: number; + wireFeeTime: Timestamp; wireMethod: string; } /** * This error is thrown when an */ -class OperationFailedAndReportedError extends Error { - constructor(public reason: Error) { - super("Reported failed operation: " + reason.message); +export class OperationFailedAndReportedError extends Error { + constructor(message: string) { + super(message); // Set the prototype explicitly. Object.setPrototypeOf(this, OperationFailedAndReportedError.prototype); } } +const logger = new Logger("wallet.ts"); + /** * The platform-independent wallet implementation. */ @@ -372,26 +375,8 @@ export class Wallet { private badge: Badge; private notifier: Notifier; private cryptoApi: CryptoApi; - private processPreCoinConcurrent = 0; - private processPreCoinThrottle: { [url: string]: number } = {}; - private timerGroup: TimerGroup; private speculativePayData: SpeculativePayData | undefined; private cachedNextUrl: { [fulfillmentUrl: string]: NextUrlResult } = {}; - private activeTipOperations: { [s: string]: Promise } = {}; - private activeProcessReserveOperations: { - [reservePub: string]: Promise; - } = {}; - private activeProcessPreCoinOperations: { - [preCoinPub: string]: Promise; - } = {}; - private activeRefreshOperations: { - [coinPub: string]: Promise; - } = {}; - - /** - * Set of identifiers for running operations. - */ - private runningOperations: Set = new Set(); constructor( db: IDBDatabase, @@ -405,10 +390,13 @@ export class Wallet { this.badge = badge; this.notifier = notifier; this.cryptoApi = new CryptoApi(cryptoWorkerFactory); - this.timerGroup = new TimerGroup(); } - public async processPending(): Promise { + /** + * Process pending operations. + */ + public async runPending(): Promise { + // FIXME: maybe prioritize pending operations by their urgency? const exchangeBaseUrlList = await oneShotIter( this.db, Stores.exchanges, @@ -417,19 +405,61 @@ export class Wallet { for (let exchangeBaseUrl of exchangeBaseUrlList) { await this.updateExchangeFromUrl(exchangeBaseUrl); } + + const reservesPubList = await oneShotIter(this.db, Stores.reserves).map( + x => x.reservePub, + ); + + for (let reservePub of reservesPubList) { + await this.processReserve(reservePub); + } + + const refreshSessionList = await oneShotIter(this.db, Stores.refresh).map( + x => x.refreshSessionId, + ); + for (let rs of refreshSessionList) { + await this.processRefreshSession(rs); + } } /** - * Start processing pending operations asynchronously. + * Process pending operations and wait for scheduled operations in + * a loop until the wallet is stopped explicitly. */ - 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()); - }; - work(); + public async runUntilStopped(): Promise { + throw Error("not implemented"); + } + + /** + * Run until all coins have been withdrawn from the given reserve, + * or an error has occured. + */ + public async runUntilReserveDepleted(reservePub: string) { + while (true) { + let reserve = await oneShotGet(this.db, Stores.reserves, reservePub); + if (!reserve) { + throw Error("Reserve does not exist."); + } + if (reserve.lastError !== undefined) { + throw Error("Reserve error: " + reserve.lastError.message); + } + if (reserve.reserveStatus === ReserveRecordStatus.UNCONFIRMED) { + throw Error("Reserve is not confirmed."); + } + if (reserve.reserveStatus === ReserveRecordStatus.DORMANT) { + // Check if all withdraws are done! + const precoins = await oneShotIterIndex( + this.db, + Stores.precoins.byReservePub, + reservePub, + ).toArray(); + for (const pc of precoins) { + await this.processPreCoin(pc.coinPub); + } + break; + } + await this.processReserve(reservePub); + } } /** @@ -457,18 +487,6 @@ export class Wallet { ); } - private startOperation(operationId: string) { - this.runningOperations.add(operationId); - this.badge.startBusy(); - } - - private stopOperation(operationId: string) { - this.runningOperations.delete(operationId); - if (this.runningOperations.size === 0) { - this.badge.stopBusy(); - } - } - async updateExchanges(): Promise { const exchangeUrls = await oneShotIter(this.db, Stores.exchanges).map( e => e.baseUrl, @@ -481,35 +499,6 @@ export class Wallet { } } - /** - * Resume various pending operations that are pending - * by looking at the database. - */ - private resumePendingFromDb(): void { - Wallet.enableTracing && console.log("resuming pending operations from db"); - - oneShotIter(this.db, Stores.reserves).forEach(reserve => { - Wallet.enableTracing && - console.log("resuming reserve", reserve.reserve_pub); - this.processReserve(reserve.reserve_pub); - }); - - oneShotIter(this.db, Stores.precoins).forEach(preCoin => { - Wallet.enableTracing && console.log("resuming precoin"); - this.processPreCoin(preCoin.coinPub); - }); - - oneShotIter(this.db, Stores.refresh).forEach((r: RefreshSessionRecord) => { - this.continueRefreshSession(r); - }); - - oneShotIter(this.db, Stores.coinsReturns).forEach( - (r: CoinsReturnRecord) => { - this.depositReturnedCoins(r); - }, - ); - } - private async getCoinsForReturn( exchangeBaseUrl: string, amount: AmountJson, @@ -752,8 +741,8 @@ export class Wallet { payReq, refundsDone: {}, refundsPending: {}, - timestamp: new Date().getTime(), - timestamp_refund: 0, + timestamp: getTimestampNow(), + timestamp_refund: undefined, }; await runWithWriteTransaction( @@ -819,7 +808,13 @@ export class Wallet { proposal.contractTerms.fulfillment_url, ); - if (differentPurchase) { + let fulfillmentUrl = proposal.contractTerms.fulfillment_url; + let doublePurchaseDetection = false; + if (fulfillmentUrl.startsWith("http")) { + doublePurchaseDetection = true; + } + + if (differentPurchase && doublePurchaseDetection) { // We do this check to prevent merchant B to find out if we bought a // digital product with merchant A by abusing the existing payment // redirect feature. @@ -870,7 +865,10 @@ export class Wallet { paymentAmount, wireFeeAmortization: proposal.contractTerms.wire_fee_amortization || 1, wireFeeLimit, - wireFeeTime: getTalerStampSec(proposal.contractTerms.timestamp) || 0, + // FIXME: parse this properly + wireFeeTime: extractTalerStamp(proposal.contractTerms.timestamp) || { + t_ms: 0, + }, wireMethod: proposal.contractTerms.wire_method, }); @@ -962,7 +960,7 @@ export class Wallet { contractTermsHash, merchantSig: proposal.sig, noncePriv: priv, - timestamp: new Date().getTime(), + timestamp: getTimestampNow(), url, downloadSessionId: sessionId, }; @@ -1143,7 +1141,10 @@ export class Wallet { paymentAmount: Amounts.parseOrThrow(proposal.contractTerms.amount), wireFeeAmortization: proposal.contractTerms.wire_fee_amortization || 1, wireFeeLimit, - wireFeeTime: getTalerStampSec(proposal.contractTerms.timestamp) || 0, + // FIXME: parse this properly + wireFeeTime: extractTalerStamp(proposal.contractTerms.timestamp) || { + t_ms: 0, + }, wireMethod: proposal.contractTerms.wire_method, }); @@ -1218,7 +1219,7 @@ export class Wallet { } /** - * Send reserve details + * Send reserve details to the bank. */ private async sendReserveInfoToBank(reservePub: string) { const reserve = await oneShotGet(this.db, Stores.reserves, reservePub); @@ -1226,12 +1227,16 @@ export class Wallet { throw Error("reserve not in db"); } + if (reserve.reserveStatus != ReserveRecordStatus.REGISTERING_BANK) { + return; + } + const bankStatusUrl = reserve.bankWithdrawStatusUrl; if (!bankStatusUrl) { - throw Error("reserve not confirmed yet, and no status URL available."); + throw Error("no bank withdraw status URL available."); } - const now = new Date().getTime(); + const now = getTimestampNow(); let status; try { const statusResp = await this.http.get(bankStatusUrl); @@ -1243,10 +1248,10 @@ export class Wallet { if (status.transfer_done) { await oneShotMutate(this.db, Stores.reserves, reservePub, r => { - r.timestamp_confirmed = now; + r.timestampConfirmed = now; return r; }); - } else if (reserve.timestamp_reserve_info_posted === 0) { + } else if (reserve.timestampReserveInfoPosted === undefined) { try { if (!status.selection_done) { const bankResp = await this.http.postJson(bankStatusUrl, { @@ -1259,7 +1264,7 @@ export class Wallet { throw e; } await oneShotMutate(this.db, Stores.reserves, reservePub, r => { - r.timestamp_reserve_info_posted = now; + r.timestampReserveInfoPosted = now; return r; }); } @@ -1268,73 +1273,38 @@ export class Wallet { /** * First fetch information requred to withdraw from the reserve, * then deplete the reserve, withdrawing coins until it is empty. + * + * The returned promise resolves once the reserve is set to the + * state DORMANT. */ async processReserve(reservePub: string): Promise { - const activeOperation = this.activeProcessReserveOperations[reservePub]; - - if (activeOperation) { - return activeOperation; + const reserve = await oneShotGet(this.db, Stores.reserves, reservePub); + if (!reserve) { + console.log("not processing reserve: reserve does not exist"); + return; } - - const opId = "reserve-" + reservePub; - this.startOperation(opId); - - // This opened promise gets resolved only once the - // reserve withdraw operation succeeds, even after retries. - const op = openPromise(); - - const processReserveInternal = async (retryDelayMs: number = 250) => { - let isHardError = false; - // By default, do random, exponential backoff truncated at 3 minutes. - // Sometimes though, we want to try again faster. - let maxTimeout = 3000 * 60; - try { - const reserve = await oneShotGet(this.db, Stores.reserves, reservePub); - if (!reserve) { - isHardError = true; - throw Error("reserve not in db"); - } - - if (reserve.timestamp_confirmed === 0) { - const bankStatusUrl = reserve.bankWithdrawStatusUrl; - if (!bankStatusUrl) { - isHardError = true; - throw Error( - "reserve not confirmed yet, and no status URL available.", - ); - } - maxTimeout = 2000; - /* This path is only taken if the wallet crashed after a withdraw was accepted, - * and before the information could be sent to the bank. */ - await this.sendReserveInfoToBank(reservePub); - throw Error("waiting for reserve to be confirmed"); - } - - const updatedReserve = await this.updateReserve(reservePub); - await this.depleteReserve(updatedReserve); - op.resolve(); - } catch (e) { - if (isHardError) { - op.reject(e); - } - const nextDelay = Math.min( - 2 * retryDelayMs + retryDelayMs * Math.random(), - maxTimeout, - ); - - this.timerGroup.after(retryDelayMs, () => - processReserveInternal(nextDelay), - ); - } - }; - - try { - processReserveInternal(); - this.activeProcessReserveOperations[reservePub] = op.promise; - await op.promise; - } finally { - this.stopOperation(opId); - delete this.activeProcessReserveOperations[reservePub]; + logger.trace( + `Processing reserve ${reservePub} with status ${reserve.reserveStatus}`, + ); + switch (reserve.reserveStatus) { + case ReserveRecordStatus.UNCONFIRMED: + // nothing to do + break; + case ReserveRecordStatus.REGISTERING_BANK: + await this.sendReserveInfoToBank(reservePub); + return this.processReserve(reservePub); + case ReserveRecordStatus.QUERYING_STATUS: + await this.updateReserve(reservePub); + return this.processReserve(reservePub); + case ReserveRecordStatus.WITHDRAWING: + await this.depleteReserve(reservePub); + break; + case ReserveRecordStatus.DORMANT: + // nothing to do + break; + default: + console.warn("unknown reserve record status:", reserve.reserveStatus); + break; } } @@ -1342,117 +1312,89 @@ export class Wallet { * Given a planchet, withdraw a coin from the exchange. */ private async processPreCoin(preCoinPub: string): Promise { - const activeOperation = this.activeProcessPreCoinOperations[preCoinPub]; - if (activeOperation) { - return activeOperation; + console.log("processPreCoin", preCoinPub); + const preCoin = await oneShotGet(this.db, Stores.precoins, preCoinPub); + if (!preCoin) { + console.log("processPreCoin: preCoinPub not found"); + return; + } + const exchange = await oneShotGet( + this.db, + Stores.exchanges, + preCoin.exchangeBaseUrl, + ); + if (!exchange) { + console.error("db inconsistent: exchange for precoin not found"); + return; } - const op = openPromise(); - - const processPreCoinInternal = async (retryDelayMs: number = 200) => { - const preCoin = await oneShotGet(this.db, Stores.precoins, preCoinPub); - if (!preCoin) { - console.log("processPreCoin: preCoinPub not found"); - return; - } - // Throttle concurrent executions of this function, - // so we don't withdraw too many coins at once. - if ( - this.processPreCoinConcurrent >= 4 || - this.processPreCoinThrottle[preCoin.exchangeBaseUrl] - ) { - const timeout = Math.min(retryDelayMs * 2, 5 * 60 * 1000); - Wallet.enableTracing && - console.log( - `throttling processPreCoin of ${preCoinPub} for ${timeout}ms`, - ); - this.timerGroup.after(retryDelayMs, () => processPreCoinInternal()); - return op.promise; - } - - this.processPreCoinConcurrent++; - - try { - const exchange = await oneShotGet( - this.db, - Stores.exchanges, - preCoin.exchangeBaseUrl, - ); - if (!exchange) { - console.error("db inconsistent: exchange for precoin not found"); - return; - } - const denom = await oneShotGet(this.db, Stores.denominations, [ - preCoin.exchangeBaseUrl, - preCoin.denomPub, - ]); - if (!denom) { - console.error("db inconsistent: denom for precoin not found"); - return; - } + const denom = await oneShotGet(this.db, Stores.denominations, [ + preCoin.exchangeBaseUrl, + preCoin.denomPub, + ]); - const coin = await this.withdrawExecute(preCoin); + if (!denom) { + console.error("db inconsistent: denom for precoin not found"); + return; + } - const mutateReserve = (r: ReserveRecord) => { - const x = Amounts.sub( - r.precoin_amount, - preCoin.coinValue, - denom.feeWithdraw, - ); - if (x.saturated) { - console.error("database inconsistent"); - throw AbortTransaction; - } - r.precoin_amount = x.amount; - return r; - }; + const wd: any = {}; + wd.denom_pub_hash = preCoin.denomPubHash; + wd.reserve_pub = preCoin.reservePub; + wd.reserve_sig = preCoin.withdrawSig; + wd.coin_ev = preCoin.coinEv; + const reqUrl = new URI("reserve/withdraw").absoluteTo(exchange.baseUrl); + const resp = await this.http.postJson(reqUrl.href(), wd); - 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); - }, - ); + const r = resp.responseJson; - this.badge.showNotification(); + const denomSig = await this.cryptoApi.rsaUnblind( + r.ev_sig, + preCoin.blindingKey, + preCoin.denomPub, + ); - this.notifier.notify(); - op.resolve(); - } catch (e) { - console.error( - "Failed to withdraw coin from precoin, retrying in", - retryDelayMs, - "ms", - e, - ); - // exponential backoff truncated at one minute - const nextRetryDelayMs = Math.min(retryDelayMs * 2, 5 * 60 * 1000); - this.timerGroup.after(retryDelayMs, () => - processPreCoinInternal(nextRetryDelayMs), - ); + const coin: CoinRecord = { + blindingKey: preCoin.blindingKey, + coinPriv: preCoin.coinPriv, + coinPub: preCoin.coinPub, + currentAmount: preCoin.coinValue, + denomPub: preCoin.denomPub, + denomPubHash: preCoin.denomPubHash, + denomSig, + exchangeBaseUrl: preCoin.exchangeBaseUrl, + reservePub: preCoin.reservePub, + status: CoinStatus.Fresh, + }; - const currentThrottle = - this.processPreCoinThrottle[preCoin.exchangeBaseUrl] || 0; - this.processPreCoinThrottle[preCoin.exchangeBaseUrl] = - currentThrottle + 1; - this.timerGroup.after(retryDelayMs, () => { - this.processPreCoinThrottle[preCoin.exchangeBaseUrl]--; - }); - } finally { - this.processPreCoinConcurrent--; + const mutateReserve = (r: ReserveRecord) => { + const x = Amounts.sub( + r.precoinAmount, + preCoin.coinValue, + denom.feeWithdraw, + ); + if (x.saturated) { + console.error("database inconsistent"); + throw TransactionAbort; } + r.precoinAmount = x.amount; + return r; }; - try { - this.activeProcessPreCoinOperations[preCoinPub] = op.promise; - await processPreCoinInternal(); - return op.promise; - } finally { - delete this.activeProcessPreCoinOperations[preCoinPub]; - } + await runWithWriteTransaction( + this.db, + [Stores.reserves, Stores.precoins, Stores.coins], + async tx => { + const currentPc = await tx.get(Stores.precoins, coin.coinPub); + if (!currentPc) { + return; + } + await tx.mutate(Stores.reserves, preCoin.reservePub, mutateReserve); + await tx.delete(Stores.precoins, coin.coinPub); + await tx.add(Stores.coins, coin); + }, + ); + logger.trace(`withdraw of one coin ${coin.coinPub} finished`); } /** @@ -1465,24 +1407,31 @@ export class Wallet { req: CreateReserveRequest, ): Promise { const keypair = await this.cryptoApi.createEddsaKeypair(); - const now = new Date().getTime(); + const now = getTimestampNow(); const canonExchange = canonicalizeBaseUrl(req.exchange); + let reserveStatus; + if (req.bankWithdrawStatusUrl) { + reserveStatus = ReserveRecordStatus.REGISTERING_BANK; + } else { + reserveStatus = ReserveRecordStatus.UNCONFIRMED; + } + const reserveRecord: ReserveRecord = { created: now, - current_amount: null, - exchange_base_url: canonExchange, + currentAmount: null, + exchangeBaseUrl: canonExchange, hasPayback: false, - precoin_amount: Amounts.getZero(req.amount.currency), - requested_amount: req.amount, - reserve_priv: keypair.priv, - reserve_pub: keypair.pub, + precoinAmount: Amounts.getZero(req.amount.currency), + requestedAmount: req.amount, + reservePriv: keypair.priv, + reservePub: keypair.pub, senderWire: req.senderWire, - timestamp_confirmed: 0, - timestamp_reserve_info_posted: 0, - timestamp_depleted: 0, + timestampConfirmed: undefined, + timestampReserveInfoPosted: undefined, bankWithdrawStatusUrl: req.bankWithdrawStatusUrl, exchangeWire: req.exchangeWire, + reserveStatus, }; const senderWire = req.senderWire; @@ -1522,7 +1471,7 @@ export class Wallet { const cr: CurrencyRecord = currencyRecord; - runWithWriteTransaction( + await runWithWriteTransaction( this.db, [Stores.currencies, Stores.reserves], async tx => { @@ -1531,9 +1480,9 @@ export class Wallet { }, ); - if (req.bankWithdrawStatusUrl) { - this.processReserve(keypair.pub); - } + this.processReserve(keypair.pub).catch(e => { + console.error("Processing reserve failed:", e); + }); const r: CreateReserveResponse = { exchange: canonExchange, @@ -1552,53 +1501,21 @@ export class Wallet { * an unconfirmed reserve should be hidden. */ async confirmReserve(req: ConfirmReserveRequest): Promise { - const now = new Date().getTime(); - 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 oneShotPut(this.db, Stores.reserves, reserve); - this.notifier.notify(); - - this.processReserve(reserve.reserve_pub); - } + const now = getTimestampNow(); + await oneShotMutate(this.db, Stores.reserves, req.reservePub, reserve => { + if (reserve.reserveStatus !== ReserveRecordStatus.UNCONFIRMED) { + return; + } + reserve.timestampConfirmed = now; + reserve.reserveStatus = ReserveRecordStatus.QUERYING_STATUS; + return reserve; + }); - private async withdrawExecute(pc: PreCoinRecord): Promise { - const wd: any = {}; - wd.denom_pub_hash = pc.denomPubHash; - wd.reserve_pub = pc.reservePub; - wd.reserve_sig = pc.withdrawSig; - wd.coin_ev = pc.coinEv; - const reqUrl = new URI("reserve/withdraw").absoluteTo(pc.exchangeBaseUrl); - const resp = await this.http.postJson(reqUrl.href(), wd); + this.notifier.notify(); - if (resp.status !== 200) { - throw new RequestException({ - hint: "Withdrawal failed", - status: resp.status, - }); - } - const r = resp.responseJson; - const denomSig = await this.cryptoApi.rsaUnblind( - r.ev_sig, - pc.blindingKey, - pc.denomPub, - ); - const coin: CoinRecord = { - blindingKey: pc.blindingKey, - coinPriv: pc.coinPriv, - coinPub: pc.coinPub, - currentAmount: pc.coinValue, - denomPub: pc.denomPub, - denomPubHash: pc.denomPubHash, - denomSig, - exchangeBaseUrl: pc.exchangeBaseUrl, - reservePub: pc.reservePub, - status: CoinStatus.Fresh, - }; - return coin; + this.processReserve(req.reservePub).catch(e => { + console.log("processing reserve failed:", e); + }); } /** @@ -1607,31 +1524,41 @@ export class Wallet { * When finished, marks the reserve as depleted by setting * the depleted timestamp. */ - private async depleteReserve(reserve: ReserveRecord): Promise { - Wallet.enableTracing && console.log("depleting reserve"); - if (!reserve.current_amount) { - throw Error("can't withdraw when amount is unknown"); + private async depleteReserve(reservePub: string): Promise { + const reserve = await oneShotGet(this.db, Stores.reserves, reservePub); + if (!reserve) { + return; } - const withdrawAmount = reserve.current_amount; + if (reserve.reserveStatus !== ReserveRecordStatus.WITHDRAWING) { + return; + } + logger.trace(`depleting reserve ${reservePub}`); + + const withdrawAmount = reserve.currentAmount; if (!withdrawAmount) { - throw Error("can't withdraw when amount is unknown"); + throw Error("BUG: reserveStatus=WITHDRAWING, but currentAmount is empty"); } + const denomsForWithdraw = await this.getVerifiedWithdrawDenomList( - reserve.exchange_base_url, + reserve.exchangeBaseUrl, withdrawAmount, ); - const smallestAmount = await this.getVerifiedSmallestWithdrawAmount( - reserve.exchange_base_url, - ); - - console.log(`withdrawing ${denomsForWithdraw.length} coins`); - - const stampMsNow = Math.floor(new Date().getTime()); + if (denomsForWithdraw.length === 0) { + const m = `Unable to withdraw from reserve, no denominations are available to withdraw.`; + await this.setReserveError(reserve.reservePub, { + type: "internal", + message: m, + details: {}, + }); + throw new OperationFailedAndReportedError(m); + } const withdrawalRecord: WithdrawalRecord = { - reservePub: reserve.reserve_pub, + reservePub: reserve.reservePub, withdrawalAmount: Amounts.toString(withdrawAmount), - startTimestamp: stampMsNow, + startTimestamp: getTimestampNow(), + numCoinsTotal: denomsForWithdraw.length, + numCoinsWithdrawn: 0, }; const preCoinRecords: PreCoinRecord[] = await Promise.all( @@ -1651,49 +1578,50 @@ export class Wallet { ).amount; function mutateReserve(r: ReserveRecord): ReserveRecord { - const currentAmount = r.current_amount; + const currentAmount = r.currentAmount; if (!currentAmount) { throw Error("can't withdraw when amount is unknown"); } - r.precoin_amount = Amounts.add( - r.precoin_amount, + r.precoinAmount = Amounts.add( + r.precoinAmount, totalWithdrawAmount, ).amount; const result = Amounts.sub(currentAmount, totalWithdrawAmount); if (result.saturated) { console.error("can't create precoins, saturated"); - throw AbortTransaction; - } - r.current_amount = result.amount; - - // Reserve is depleted if the amount left is too small to withdraw - if (Amounts.cmp(r.current_amount, smallestAmount) < 0) { - r.timestamp_depleted = new Date().getTime(); + throw TransactionAbort; } + r.currentAmount = result.amount; + r.reserveStatus = ReserveRecordStatus.DORMANT; return r; } - // This will fail and throw an exception if the remaining amount in the - // reserve is too low to create a pre-coin. - try { - 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; - } + const success = await runWithWriteTransaction( + this.db, + [Stores.precoins, Stores.withdrawals, Stores.reserves], + async tx => { + const myReserve = await tx.get(Stores.reserves, reservePub); + if (!myReserve) { + return false; + } + if (myReserve.reserveStatus !== ReserveRecordStatus.WITHDRAWING) { + return false; + } + for (let pcr of preCoinRecords) { + await tx.put(Stores.precoins, pcr); + } + await tx.mutate(Stores.reserves, reserve.reservePub, mutateReserve); + await tx.put(Stores.withdrawals, withdrawalRecord); + return true; + }, + ); - for (let x of preCoinRecords) { - await this.processPreCoin(x.coinPub); + if (success) { + logger.trace(`withdrawing ${preCoinRecords.length} coins`); + for (let x of preCoinRecords) { + await this.processPreCoin(x.coinPub); + } } } @@ -1701,34 +1629,52 @@ export class Wallet { * Update the information about a reserve that is stored in the wallet * by quering the reserve's exchange. */ - private async updateReserve(reservePub: string): Promise { + private async updateReserve(reservePub: string): Promise { const reserve = await oneShotGet(this.db, Stores.reserves, reservePub); if (!reserve) { throw Error("reserve not in db"); } - if (reserve.timestamp_confirmed === 0) { - throw Error(""); + if (reserve.timestampConfirmed === undefined) { + throw Error("reserve not confirmed yet"); + } + + if (reserve.reserveStatus !== ReserveRecordStatus.QUERYING_STATUS) { + return; } const reqUrl = new URI("reserve/status").absoluteTo( - reserve.exchange_base_url, + reserve.exchangeBaseUrl, ); reqUrl.query({ reserve_pub: reservePub }); - const resp = await this.http.get(reqUrl.href()); - if (resp.status !== 200) { - Wallet.enableTracing && - console.warn(`reserve/status returned ${resp.status}`); - throw Error(); + let resp; + try { + resp = await this.http.get(reqUrl.href()); + } catch (e) { + if (e.response?.status === 404) { + console.log("Reserve now known to exchange (yet)."); + return; + } else { + const m = e.message; + this.setReserveError(reservePub, { + type: "network", + details: {}, + message: m, + }); + throw new OperationFailedAndReportedError(m); + } } const reserveInfo = ReserveStatus.checked(resp.responseJson); - if (!reserveInfo) { - throw Error(); - } - reserve.current_amount = Amounts.parseOrThrow(reserveInfo.balance); + await oneShotMutate(this.db, Stores.reserves, reserve.reservePub, r => { + if (r.reserveStatus !== ReserveRecordStatus.QUERYING_STATUS) { + return; + } + reserve.currentAmount = Amounts.parseOrThrow(reserveInfo.balance); + reserve.reserveStatus = ReserveRecordStatus.WITHDRAWING; + return r; + }); await oneShotPut(this.db, Stores.reserves, reserve); this.notifier.notify(); - return reserve; } async getPossibleDenoms( @@ -1984,7 +1930,8 @@ export class Wallet { versionMatch.currentCmp === -1 ) { console.warn( - `wallet version ${WALLET_PROTOCOL_VERSION} might be outdated (exchange has ${exchangeDetails.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"); @@ -2060,19 +2007,25 @@ export class Wallet { wireInfo: undefined, updateStatus: ExchangeUpdateStatus.FETCH_KEYS, updateStarted: now, + updateReason: "initial", + timestampAdded: getTimestampNow(), }; await oneShotPut(this.db, Stores.exchanges, newExchangeRecord); } else { - runWithWriteTransaction(this.db, [Stores.exchanges], async t => { + await runWithWriteTransaction(this.db, [Stores.exchanges], async t => { const rec = await t.get(Stores.exchanges, baseUrl); if (!rec) { return; } - if (rec.updateStatus != ExchangeUpdateStatus.NONE && !force) { + if (rec.updateStatus != ExchangeUpdateStatus.FETCH_KEYS && !force) { return; } + if (rec.updateStatus != ExchangeUpdateStatus.FETCH_KEYS && force) { + rec.updateReason = "forced"; + } rec.updateStarted = now; rec.updateStatus = ExchangeUpdateStatus.FETCH_KEYS; + rec.lastError = undefined; t.put(Stores.exchanges, rec); }); } @@ -2104,6 +2057,17 @@ export class Wallet { await oneShotMutate(this.db, Stores.exchanges, baseUrl, mut); } + private async setReserveError( + reservePub: string, + err: OperationError, + ): Promise { + const mut = (reserve: ReserveRecord) => { + reserve.lastError = err; + return reserve; + }; + await oneShotMutate(this.db, Stores.reserves, reservePub, mut); + } + /** * Fetch the exchange's /keys and update our database accordingly. * @@ -2129,23 +2093,27 @@ export class Wallet { try { keysResp = await this.http.get(keysUrl.href()); } catch (e) { + const m = `Fetching keys failed: ${e.message}`; await this.setExchangeError(baseUrl, { type: "network", - details: {}, - message: `Fetching keys failed: ${e.message}`, + details: { + requestUrl: e.config?.url, + }, + message: m, }); - throw e; + throw new OperationFailedAndReportedError(m); } let exchangeKeysJson: KeysJson; try { exchangeKeysJson = KeysJson.checked(keysResp.responseJson); } catch (e) { + const m = `Parsing /keys response failed: ${e.message}`; await this.setExchangeError(baseUrl, { type: "protocol-violation", details: {}, - message: `Parsing /keys response failed: ${e.message}`, + message: m, }); - throw e; + throw new OperationFailedAndReportedError(m); } const lastUpdateTimestamp = extractTalerStamp( @@ -2158,7 +2126,7 @@ export class Wallet { details: {}, message: m, }); - throw Error(m); + throw new OperationFailedAndReportedError(m); } if (exchangeKeysJson.denoms.length === 0) { @@ -2168,7 +2136,7 @@ export class Wallet { details: {}, message: m, }); - throw Error(m); + throw new OperationFailedAndReportedError(m); } const protocolVersion = exchangeKeysJson.version; @@ -2179,32 +2147,69 @@ export class Wallet { details: {}, message: m, }); - throw Error(m); + throw new OperationFailedAndReportedError(m); } const currency = Amounts.parseOrThrow(exchangeKeysJson.denoms[0].value) .currency; - 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, - }; - r.updateStatus = ExchangeUpdateStatus.FETCH_WIRE; - r.lastError = undefined; - return r; - }; + const newDenominations = await Promise.all( + exchangeKeysJson.denoms.map(d => + this.denominationRecordFromKeys(baseUrl, d), + ), + ); + + await runWithWriteTransaction( + this.db, + [Stores.exchanges, Stores.denominations], + async tx => { + const r = await tx.get(Stores.exchanges, baseUrl); + if (!r) { + console.warn(`exchange ${baseUrl} no longer present`); + return; + } + if (r.details) { + // FIXME: We need to do some consistency checks! + } + r.details = { + auditors: exchangeKeysJson.auditors, + currency: currency, + lastUpdateTime: lastUpdateTimestamp, + masterPublicKey: exchangeKeysJson.master_public_key, + protocolVersion: protocolVersion, + }; + r.updateStatus = ExchangeUpdateStatus.FETCH_WIRE; + r.lastError = undefined; + await tx.put(Stores.exchanges, r); + + for (const newDenom of newDenominations) { + const oldDenom = await tx.get(Stores.denominations, [ + baseUrl, + newDenom.denomPub, + ]); + if (oldDenom) { + // FIXME: Do consistency check + } else { + await tx.put(Stores.denominations, newDenom); + } + } + }, + ); } + /** + * Fetch wire information for an exchange and store it in the database. + * + * @param exchangeBaseUrl Exchange base URL, assumed to be already normalized. + */ private async updateExchangeWithWireInfo(exchangeBaseUrl: string) { - exchangeBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl); + const exchange = await this.findExchange(exchangeBaseUrl); + if (!exchange) { + return; + } + if (exchange.updateStatus != ExchangeUpdateStatus.FETCH_WIRE) { + return; + } const reqUrl = new URI("wire") .absoluteTo(exchangeBaseUrl) .addQuery("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION); @@ -2215,6 +2220,45 @@ export class Wallet { throw Error("/wire response malformed"); } const wireInfo = ExchangeWireJson.checked(wiJson); + const feesForType: { [wireMethod: string]: WireFee[] } = {}; + for (const wireMethod of Object.keys(wireInfo.fees)) { + const feeList: WireFee[] = []; + for (const x of wireInfo.fees[wireMethod]) { + const startStamp = extractTalerStamp(x.start_date); + if (!startStamp) { + throw Error("wrong date format"); + } + const endStamp = extractTalerStamp(x.end_date); + if (!endStamp) { + throw Error("wrong date format"); + } + feeList.push({ + closingFee: Amounts.parseOrThrow(x.closing_fee), + endStamp, + sig: x.sig, + startStamp, + wireFee: Amounts.parseOrThrow(x.wire_fee), + }); + } + feesForType[wireMethod] = feeList; + } + + await runWithWriteTransaction(this.db, [Stores.exchanges], async tx => { + const r = await tx.get(Stores.exchanges, exchangeBaseUrl); + if (!r) { + return; + } + if (r.updateStatus != ExchangeUpdateStatus.FETCH_WIRE) { + return; + } + r.wireInfo = { + accounts: wireInfo.accounts, + feesForType: feesForType, + }; + r.updateStatus = ExchangeUpdateStatus.FINISHED; + r.lastError = undefined; + await tx.put(Stores.exchanges, r); + }); } /** @@ -2312,17 +2356,17 @@ export class Wallet { }); await tx.iter(Stores.reserves).forEach(r => { - if (!r.timestamp_confirmed) { + if (!r.timestampConfirmed) { 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); + let amount = Amounts.getZero(r.requestedAmount.currency); + amount = Amounts.add(amount, r.precoinAmount).amount; + addTo(balanceStore, "pendingIncoming", amount, r.exchangeBaseUrl); addTo( balanceStore, "pendingIncomingWithdraw", amount, - r.exchange_base_url, + r.exchangeBaseUrl, ); }); @@ -2333,8 +2377,8 @@ export class Wallet { addTo( balanceStore, "paybackAmount", - r.current_amount!, - r.exchange_base_url, + r.currentAmount!, + r.exchangeBaseUrl, ); return balanceStore; }); @@ -2359,23 +2403,27 @@ export class Wallet { return balanceStore; } - async createRefreshSession( - oldCoinPub: string, - ): Promise { + async refresh(oldCoinPub: string, force: boolean = false): Promise { const coin = await oneShotGet(this.db, Stores.coins, oldCoinPub); - if (!coin) { - throw Error("coin not found"); + console.warn("can't refresh, coin not in database"); + return; } - - if (coin.currentAmount.value === 0 && coin.currentAmount.fraction === 0) { - return undefined; + switch (coin.status) { + case CoinStatus.Dirty: + break; + case CoinStatus.Dormant: + return; + case CoinStatus.Fresh: + if (!force) { + return; + } + break; } const exchange = await this.updateExchangeFromUrl(coin.exchangeBaseUrl); - if (!exchange) { - throw Error("db inconsistent"); + throw Error("db inconsistent: exchange of coin not found"); } const oldDenom = await oneShotGet(this.db, Stores.denominations, [ @@ -2384,7 +2432,7 @@ export class Wallet { ]); if (!oldDenom) { - throw Error("db inconsistent"); + throw Error("db inconsistent: denomination for coin not found"); } const availableDenoms: DenominationRecord[] = await oneShotIterIndex( @@ -2401,20 +2449,22 @@ export class Wallet { availableDenoms, ); - Wallet.enableTracing && console.log("refreshing coin", coin); - Wallet.enableTracing && console.log("refreshing into", newCoinDenoms); - if (newCoinDenoms.length === 0) { - Wallet.enableTracing && - console.log( - `not refreshing, available amount ${amountToPretty( - availableAmount, - )} too small`, - ); - coin.status = CoinStatus.Useless; - await oneShotPut(this.db, Stores.coins, coin); + logger.trace( + `not refreshing, available amount ${amountToPretty( + availableAmount, + )} too small`, + ); + await oneShotMutate(this.db, Stores.coins, oldCoinPub, x => { + if (x.status != coin.status) { + // Concurrent modification? + return; + } + x.status = CoinStatus.Dormant; + return x; + }); this.notifier.notify(); - return undefined; + return; } const refreshSession: RefreshSessionRecord = await this.cryptoApi.createRefreshSession( @@ -2429,114 +2479,58 @@ export class Wallet { const r = Amounts.sub(c.currentAmount, refreshSession.valueWithFee); if (r.saturated) { // Something else must have written the coin value - throw AbortTransaction; + throw TransactionAbort; } c.currentAmount = r.amount; - c.status = CoinStatus.Refreshed; + c.status = CoinStatus.Dormant; return c; } - let key; - // Store refresh session and subtract refreshed amount from // coin in the same transaction. await runWithWriteTransaction( this.db, [Stores.refresh, Stores.coins], async tx => { - key = await tx.put(Stores.refresh, refreshSession); + await tx.put(Stores.refresh, refreshSession); await tx.mutate(Stores.coins, coin.coinPub, mutateCoin); }, ); + logger.info(`created refresh session ${refreshSession.refreshSessionId}`); this.notifier.notify(); - if (!key || typeof key !== "number") { - throw Error("insert failed"); - } - - refreshSession.id = key; - - return refreshSession; + await this.processRefreshSession(refreshSession.refreshSessionId); } - async refresh(oldCoinPub: string): Promise { - const refreshImpl = async () => { - 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, - ); - await this.continueRefreshSession(session); - } - const coin = await oneShotGet(this.db, Stores.coins, oldCoinPub); - if (!coin) { - console.warn("can't refresh, coin not in database"); - return; - } - if ( - coin.status === CoinStatus.Useless || - coin.status === CoinStatus.Fresh - ) { - Wallet.enableTracing && - console.log( - "not refreshing due to coin status", - CoinStatus[coin.status], - ); - return; - } - const refreshSession = await this.createRefreshSession(oldCoinPub); - if (!refreshSession) { - // refreshing not necessary - Wallet.enableTracing && console.log("not refreshing", oldCoinPub); - return; - } - return this.continueRefreshSession(refreshSession); - }; - - const activeRefreshOp = this.activeRefreshOperations[oldCoinPub]; - - if (activeRefreshOp) { - return activeRefreshOp; - } - - try { - const newOp = refreshImpl(); - this.activeRefreshOperations[oldCoinPub] = newOp; - const res = await newOp; - return res; - } finally { - delete this.activeRefreshOperations[oldCoinPub]; + async processRefreshSession(refreshSessionId: string) { + const refreshSession = await oneShotGet( + this.db, + Stores.refresh, + refreshSessionId, + ); + if (!refreshSession) { + return; } - } - - async continueRefreshSession(refreshSession: RefreshSessionRecord) { if (refreshSession.finished) { return; } if (typeof refreshSession.norevealIndex !== "number") { - await this.refreshMelt(refreshSession); - const r = await oneShotGet(this.db, Stores.refresh, refreshSession.id); - if (!r) { - throw Error("refresh session does not exist anymore"); - } - refreshSession = r; + await this.refreshMelt(refreshSession.refreshSessionId); } - - await this.refreshReveal(refreshSession); + await this.refreshReveal(refreshSession.refreshSessionId); + logger.trace("refresh finished"); } - async refreshMelt(refreshSession: RefreshSessionRecord): Promise { + async refreshMelt(refreshSessionId: string): Promise { + const refreshSession = await oneShotGet( + this.db, + Stores.refresh, + refreshSessionId, + ); + if (!refreshSession) { + return; + } if (refreshSession.norevealIndex !== undefined) { - console.error("won't melt again"); return; } @@ -2582,12 +2576,29 @@ export class Wallet { refreshSession.norevealIndex = norevealIndex; - await oneShotPut(this.db, Stores.refresh, refreshSession); + await oneShotMutate(this.db, Stores.refresh, refreshSessionId, rs => { + if (rs.norevealIndex !== undefined) { + return; + } + if (rs.finished) { + return; + } + rs.norevealIndex = norevealIndex; + return rs; + }); this.notifier.notify(); } - async refreshReveal(refreshSession: RefreshSessionRecord): Promise { + private async refreshReveal(refreshSessionId: string): Promise { + const refreshSession = await oneShotGet( + this.db, + Stores.refresh, + refreshSessionId, + ); + if (!refreshSession) { + return; + } const norevealIndex = refreshSession.norevealIndex; if (norevealIndex === undefined) { throw Error("can't reveal without melting first"); @@ -2706,6 +2717,13 @@ export class Wallet { this.db, [Stores.coins, Stores.refresh], async tx => { + const rs = await tx.get(Stores.refresh, refreshSessionId); + if (!rs) { + return; + } + if (rs.finished) { + return; + } for (let coin of coins) { await tx.put(Stores.coins, coin); } @@ -2726,8 +2744,8 @@ export class Wallet { */ async getHistory( historyQuery?: HistoryQuery, - ): Promise<{ history: HistoryRecord[] }> { - const history: HistoryRecord[] = []; + ): Promise<{ history: HistoryEvent[] }> { + const history: HistoryEvent[] = []; // FIXME: do pagination instead of generating the full history @@ -2744,6 +2762,7 @@ export class Wallet { }, timestamp: p.timestamp, type: "claim-order", + explicit: false, }); } @@ -2758,6 +2777,7 @@ export class Wallet { }, timestamp: w.startTimestamp, type: "withdraw", + explicit: false, }); } @@ -2772,6 +2792,7 @@ export class Wallet { }, timestamp: p.timestamp, type: "pay", + explicit: false, }); if (p.timestamp_refund) { const contractAmount = Amounts.parseOrThrow(p.contractTerms.amount); @@ -2796,6 +2817,7 @@ export class Wallet { }, timestamp: p.timestamp_refund, type: "refund", + explicit: false, }); } } @@ -2803,24 +2825,31 @@ export class Wallet { const reserves = await oneShotIter(this.db, Stores.reserves).toArray(); for (const r of reserves) { + const reserveType = r.bankWithdrawStatusUrl ? "taler-bank" : "manual"; history.push({ detail: { - exchangeBaseUrl: r.exchange_base_url, - requestedAmount: Amounts.toString(r.requested_amount), - reservePub: r.reserve_pub, + exchangeBaseUrl: r.exchangeBaseUrl, + requestedAmount: Amounts.toString(r.requestedAmount), + reservePub: r.reservePub, + reserveType, + bankWithdrawStatusUrl: r.bankWithdrawStatusUrl, }, timestamp: r.created, - type: "create-reserve", + type: "reserve-created", + explicit: false, }); - if (r.timestamp_depleted) { + if (r.timestampConfirmed) { history.push({ detail: { - exchangeBaseUrl: r.exchange_base_url, - requestedAmount: r.requested_amount, - reservePub: r.reserve_pub, + exchangeBaseUrl: r.exchangeBaseUrl, + requestedAmount: Amounts.toString(r.requestedAmount), + reservePub: r.reservePub, + reserveType, + bankWithdrawStatusUrl: r.bankWithdrawStatusUrl, }, - timestamp: r.timestamp_depleted, - type: "depleted-reserve", + timestamp: r.created, + type: "reserve-confirmed", + explicit: false, }); } } @@ -2835,11 +2864,23 @@ export class Wallet { tipId: tip.tipId, }, timestamp: tip.timestamp, + explicit: false, type: "tip", }); } - history.sort((h1, h2) => Math.sign(h1.timestamp - h2.timestamp)); + await oneShotIter(this.db, Stores.exchanges).forEach(exchange => { + history.push({ + type: "exchange-added", + explicit: false, + timestamp: exchange.timestampAdded, + detail: { + exchangeBaseUrl: exchange.baseUrl, + }, + }); + }); + + history.sort((h1, h2) => Math.sign(h1.timestamp.t_ms - h2.timestamp.t_ms)); return { history }; } @@ -2849,7 +2890,17 @@ export class Wallet { const exchanges = await this.getExchanges(); for (let e of exchanges) { switch (e.updateStatus) { - case ExchangeUpdateStatus.NONE: + case ExchangeUpdateStatus.FINISHED: + if (e.lastError) { + pendingOperations.push({ + type: "bug", + message: + "Exchange record is in FINISHED state but has lastError set", + details: { + exchangeBaseUrl: e.baseUrl, + }, + }); + } if (!e.details) { pendingOperations.push({ type: "bug", @@ -2860,12 +2911,24 @@ export class Wallet { }, }); } + if (!e.wireInfo) { + pendingOperations.push({ + type: "bug", + message: + "Exchange record does not have wire info, 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, + lastError: e.lastError, + reason: e.updateReason || "unknown", }); break; case ExchangeUpdateStatus.FETCH_WIRE: @@ -2873,10 +2936,79 @@ export class Wallet { type: "exchange-update", stage: "fetch-wire", exchangeBaseUrl: e.baseUrl, + lastError: e.lastError, + reason: e.updateReason || "unknown", + }); + break; + default: + pendingOperations.push({ + type: "bug", + message: "Unknown exchangeUpdateStatus", + details: { + exchangeBaseUrl: e.baseUrl, + exchangeUpdateStatus: e.updateStatus, + }, }); break; } } + await oneShotIter(this.db, Stores.reserves).forEach(reserve => { + const reserveType = reserve.bankWithdrawStatusUrl + ? "taler-bank" + : "manual"; + switch (reserve.reserveStatus) { + case ReserveRecordStatus.DORMANT: + // nothing to report as pending + break; + case ReserveRecordStatus.WITHDRAWING: + case ReserveRecordStatus.UNCONFIRMED: + case ReserveRecordStatus.QUERYING_STATUS: + pendingOperations.push({ + type: "reserve", + stage: reserve.reserveStatus, + timestampCreated: reserve.created, + reserveType, + }); + break; + default: + pendingOperations.push({ + type: "bug", + message: "Unknown reserve record status", + details: { + reservePub: reserve.reservePub, + reserveStatus: reserve.reserveStatus, + }, + }); + break; + } + }); + + await oneShotIter(this.db, Stores.refresh).forEach(r => { + if (r.finished) { + return; + } + let refreshStatus: string; + if (r.norevealIndex === undefined) { + refreshStatus = "melt"; + } else { + refreshStatus = "reveal"; + } + + pendingOperations.push({ + type: "refresh", + oldCoinPub: r.meltCoinPub, + refreshStatus, + refreshOutputSize: r.newDenoms.length, + }); + }); + + await oneShotIter(this.db, Stores.precoins).forEach(pc => { + pendingOperations.push({ + type: "withdraw", + stage: "planchet", + reservePub: pc.reservePub, + }); + }); return { pendingOperations, }; @@ -2914,16 +3046,20 @@ export class Wallet { async getReserves(exchangeBaseUrl: string): Promise { return await oneShotIter(this.db, Stores.reserves).filter( - r => r.exchange_base_url === exchangeBaseUrl, + r => r.exchangeBaseUrl === exchangeBaseUrl, ); } - async getCoins(exchangeBaseUrl: string): Promise { + async getCoinsForExchange(exchangeBaseUrl: string): Promise { return await oneShotIter(this.db, Stores.coins).filter( c => c.exchangeBaseUrl === exchangeBaseUrl, ); } + async getCoins(): Promise { + return await oneShotIter(this.db, Stores.coins).toArray(); + } + async getPreCoins(exchangeBaseUrl: string): Promise { return await oneShotIter(this.db, Stores.precoins).filter( c => c.exchangeBaseUrl === exchangeBaseUrl, @@ -2948,15 +3084,10 @@ export class Wallet { throw Error(`Reserve of coin ${coinPub} not found`); } switch (coin.status) { - case CoinStatus.Refreshed: - throw Error( - `Can't do payback for coin ${coinPub} since it's refreshed`, - ); - case CoinStatus.PaybackDone: - console.log(`Coin ${coinPub} already payed back`); - return; + case CoinStatus.Dormant: + throw Error(`Can't do payback for coin ${coinPub} since it's dormant`); } - coin.status = CoinStatus.PaybackPending; + coin.status = CoinStatus.Dormant; // Even if we didn't get the payback yet, we suspend withdrawal, since // technically we might update reserve status before we get the response // from the reserve for the payback request. @@ -2985,7 +3116,7 @@ export class Wallet { if (!coin) { throw Error(`Coin ${coinPub} not found, can't confirm payback`); } - coin.status = CoinStatus.PaybackDone; + coin.status = CoinStatus.Dormant; await oneShotPut(this.db, Stores.coins, coin); this.notifier.notify(); await this.updateReserve(reservePub!); @@ -3023,7 +3154,9 @@ export class Wallet { } reserve.hasPayback = false; await oneShotPut(this.db, Stores.reserves, reserve); - this.depleteReserve(reserve); + this.depleteReserve(reserve.reservePub).catch(e => { + console.error("Error depleting reserve after payback", e); + }); } async getPaybackReserves(): Promise { @@ -3036,7 +3169,7 @@ export class Wallet { * Stop ongoing processing. */ stop() { - this.timerGroup.stopCurrentAndFutureTimers(); + //this.timerGroup.stopCurrentAndFutureTimers(); this.cryptoApi.stop(); } @@ -3249,7 +3382,7 @@ export class Wallet { return; } - t.timestamp_refund = new Date().getTime(); + t.timestamp_refund = getTimestampNow(); for (const perm of refundPermissions) { if ( @@ -3444,25 +3577,10 @@ export class Wallet { return feeAcc; } - async acceptTip(talerTipUri: string): Promise { - const { tipId, merchantOrigin } = await this.getTipStatus(talerTipUri); - const key = `${tipId}${merchantOrigin}`; - if (this.activeTipOperations[key]) { - return this.activeTipOperations[key]; - } - const p = this.acceptTipImpl(tipId, merchantOrigin); - this.activeTipOperations[key] = p; - try { - return await p; - } finally { - delete this.activeTipOperations[key]; - } - } - - private async acceptTipImpl( - tipId: string, - merchantOrigin: string, +async acceptTip( + talerTipUri: string, ): Promise { + const { tipId, merchantOrigin } = await this.getTipStatus(talerTipUri); let tipRecord = await oneShotGet(this.db, Stores.tips, [ tipId, merchantOrigin, @@ -3603,7 +3721,7 @@ export class Wallet { pickedUp: false, planchets: undefined, response: undefined, - timestamp: new Date().getTime(), + timestamp: getTimestampNow(), tipId: res.tipId, pickupUrl: res.tipPickupUrl, totalFees: Amounts.add( @@ -3732,7 +3850,6 @@ export class Wallet { senderWire: withdrawInfo.senderWire, exchangeWire: exchangeWire, }); - await this.sendReserveInfoToBank(reserve.reservePub); return { reservePub: reserve.reservePub, confirmTransferUrl: withdrawInfo.confirmTransferUrl, @@ -3767,7 +3884,7 @@ export class Wallet { const totalFees = totalRefundFees; return { contractTerms: purchase.contractTerms, - hasRefund: purchase.timestamp_refund !== 0, + hasRefund: purchase.timestamp_refund !== undefined, totalRefundAmount: totalRefundAmount, totalRefundAndRefreshFees: totalFees, }; diff --git a/src/walletTypes.ts b/src/walletTypes.ts index b227ca816..a11da029f 100644 --- a/src/walletTypes.ts +++ b/src/walletTypes.ts @@ -233,7 +233,7 @@ export interface ConfirmPayResult { /** * Activity history record. */ -export interface HistoryRecord { +export interface HistoryEvent { /** * Type of the history event. */ @@ -242,7 +242,7 @@ export interface HistoryRecord { /** * Time when the activity was recorded. */ - timestamp: number; + timestamp: Timestamp; /** * Subject of the entry. Used to group multiple history records together. @@ -254,6 +254,13 @@ export interface HistoryRecord { * Details used when rendering the history record. */ detail: any; + + /** + * Set to 'true' if the event has been explicitly created, + * and set to 'false' if the event has been derived from the + * state of the database. + */ + explicit: boolean; } /** @@ -516,6 +523,8 @@ export interface WalletDiagnostics { export interface PendingWithdrawOperation { type: "withdraw"; + stage: string; + reservePub: string; } export interface PendingRefreshOperation { @@ -535,6 +544,7 @@ export interface OperationError { export interface PendingExchangeUpdateOperation { type: "exchange-update"; stage: string; + reason: string; exchangeBaseUrl: string; lastError?: OperationError; } @@ -545,10 +555,28 @@ export interface PendingBugOperation { details: any; } +export interface PendingReserveOperation { + type: "reserve"; + lastError?: OperationError; + stage: string; + timestampCreated: Timestamp; + reserveType: string; +} + +export interface PendingRefreshOperation { + type: "refresh"; + lastError?: OperationError; + oldCoinPub: string; + refreshStatus: string; + refreshOutputSize: number; +} + export type PendingOperationInfo = | PendingWithdrawOperation + | PendingReserveOperation | PendingBugOperation - | PendingExchangeUpdateOperation; + | PendingExchangeUpdateOperation + | PendingRefreshOperation; export interface PendingOperationsResponse { pendingOperations: PendingOperationInfo[]; diff --git a/src/webex/messages.ts b/src/webex/messages.ts index 3f6e5cc4a..034bf2849 100644 --- a/src/webex/messages.ts +++ b/src/webex/messages.ts @@ -79,7 +79,7 @@ export interface MessageMap { }; "get-history": { request: {}; - response: walletTypes.HistoryRecord[]; + response: walletTypes.HistoryEvent[]; }; "get-coins": { request: { exchangeBaseUrl: string }; diff --git a/src/webex/pages/payback.tsx b/src/webex/pages/payback.tsx index 934c28c0a..af14b95d4 100644 --- a/src/webex/pages/payback.tsx +++ b/src/webex/pages/payback.tsx @@ -57,11 +57,11 @@ function Payback() {
{reserves.map(r => (
-

Reserve for ${renderAmount(r.current_amount!)}

+

Reserve for ${renderAmount(r.currentAmount!)}

    -
  • Exchange: ${r.exchange_base_url}
  • +
  • Exchange: ${r.exchangeBaseUrl}
-
diff --git a/src/webex/pages/popup.tsx b/src/webex/pages/popup.tsx index 205945471..78b7374b3 100644 --- a/src/webex/pages/popup.tsx +++ b/src/webex/pages/popup.tsx @@ -30,7 +30,7 @@ import { AmountJson } from "../../amounts"; import * as Amounts from "../../amounts"; import { - HistoryRecord, + HistoryEvent, WalletBalance, WalletBalanceEntry, } from "../../walletTypes"; @@ -327,7 +327,7 @@ class WalletBalanceView extends React.Component { } } -function formatHistoryItem(historyItem: HistoryRecord) { +function formatHistoryItem(historyItem: HistoryEvent) { const d = historyItem.detail; console.log("hist item", historyItem); switch (historyItem.type) { @@ -459,7 +459,7 @@ class WalletHistory extends React.Component { render(): JSX.Element { console.log("rendering history"); - const history: HistoryRecord[] = this.myHistory; + const history: HistoryEvent[] = this.myHistory; if (this.gotError) { return i18n.str`Error: could not retrieve event history`; } @@ -474,7 +474,7 @@ class WalletHistory extends React.Component { const item = (
- {new Date(record.timestamp).toString()} + {new Date(record.timestamp.t_ms).toString()}
{formatHistoryItem(record)}
diff --git a/src/webex/renderHtml.tsx b/src/webex/renderHtml.tsx index f2cccfba6..c2fdb1f14 100644 --- a/src/webex/renderHtml.tsx +++ b/src/webex/renderHtml.tsx @@ -215,7 +215,7 @@ function FeeDetailsView(props: { {rci!.wireFees.feesForType[s].map(f => ( - {moment.unix(f.endStamp).format("llll")} + {moment.unix(Math.floor(f.endStamp.t_ms / 1000)).format("llll")} {renderAmount(f.wireFee)} {renderAmount(f.closingFee)} diff --git a/src/webex/wxBackend.ts b/src/webex/wxBackend.ts index f4decbc60..57c10d94a 100644 --- a/src/webex/wxBackend.ts +++ b/src/webex/wxBackend.ts @@ -176,7 +176,7 @@ async function handleMessage( if (typeof detail.exchangeBaseUrl !== "string") { return Promise.reject(Error("exchangBaseUrl missing")); } - return needsWallet().getCoins(detail.exchangeBaseUrl); + return needsWallet().getCoinsForExchange(detail.exchangeBaseUrl); } case "get-precoins": { if (typeof detail.exchangeBaseUrl !== "string") { diff --git a/tsconfig.json b/tsconfig.json index 25087b600..bcab91de2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,6 +33,8 @@ "src/crypto/cryptoWorker.ts", "src/crypto/emscInterface-test.ts", "src/crypto/emscInterface.ts", + "src/crypto/nativeCrypto-test.ts", + "src/crypto/nativeCrypto.ts", "src/crypto/nodeEmscriptenLoader.ts", "src/crypto/nodeProcessWorker.ts", "src/crypto/nodeWorkerEntry.ts", @@ -53,6 +55,7 @@ "src/index.ts", "src/libtoolVersion-test.ts", "src/libtoolVersion.ts", + "src/logging.ts", "src/promiseUtils.ts", "src/query.ts", "src/talerTypes.ts", diff --git a/yarn.lock b/yarn.lock index 4c9012d46..aeec2b42b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3412,10 +3412,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.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== +idb-bridge@^0.0.14: + version "0.0.14" + resolved "https://registry.yarnpkg.com/idb-bridge/-/idb-bridge-0.0.14.tgz#5fd50cd68b574df0eb6b1a960cef0cb984a21ded" + integrity sha512-jc9ZYGhhIrW6nh/pWyycGWzCmsLTFQ0iMY61lN+y9YcIOCxREpAkZxdfmhwNL7H0RvsYp7iJv0GH7ujs7HPC+g== ieee754@^1.1.4: version "1.1.13" -- cgit v1.2.3