From aaf7e1338d6cdb1b4e01ad318938b3eaea2f922b Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Sat, 30 Nov 2019 00:36:20 +0100 Subject: [PATCH] wallet robustness WIP --- gulpfile.js | 4 +- package.json | 2 +- src/crypto/cryptoApi.ts | 16 +- src/crypto/cryptoImplementation.ts | 25 +- src/crypto/primitives/kdf.ts | 2 +- src/crypto/talerCrypto.ts | 3 + src/dbTypes.ts | 164 ++++-- src/headless/bank.ts | 31 + src/headless/clk.ts | 15 +- src/headless/helpers.ts | 18 +- src/headless/merchant.ts | 64 ++- src/headless/taler-wallet-cli.ts | 183 ++++-- src/wallet-test.ts | 2 + src/wallet.ts | 881 ++++++++++++++++++++--------- src/walletTypes.ts | 49 +- src/webex/messages.ts | 6 +- src/webex/pages/payback.tsx | 2 +- src/webex/wxApi.ts | 10 +- src/webex/wxBackend.ts | 21 +- yarn.lock | 8 +- 20 files changed, 1075 insertions(+), 431 deletions(-) diff --git a/gulpfile.js b/gulpfile.js index 698944b29..dbdb33cc0 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -60,8 +60,6 @@ const paths = { dist: [ "dist/*-bundle.js", "dist/*-bundle.js.map", - "emscripten/taler-emscripten-lib.js", - "emscripten/taler-emscripten-lib.wasm", "img/icon.png", "img/logo.png", "src/webex/**/*.{js,css,html}", @@ -149,7 +147,7 @@ function dist_prod() { } function compile_prod(callback) { - let config = require("./webpack.config.js")({ prod: true }); + let config = require("./webpack.config.js")({ mode: "production" }); webpack(config, function(err, stats) { if (err) { throw new gutil.PluginError("webpack", err); diff --git a/package.json b/package.json index df4703243..fcff69c99 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "@types/urijs": "^1.19.3", "axios": "^0.19.0", "big-integer": "^1.6.48", - "idb-bridge": "^0.0.14", + "idb-bridge": "^0.0.15", "qrcode-generator": "^1.4.3", "source-map-support": "^0.5.12", "urijs": "^1.18.10" diff --git a/src/crypto/cryptoApi.ts b/src/crypto/cryptoApi.ts index 46fe2576e..b5eae9beb 100644 --- a/src/crypto/cryptoApi.ts +++ b/src/crypto/cryptoApi.ts @@ -27,7 +27,7 @@ import { AmountJson } from "../amounts"; import { CoinRecord, DenominationRecord, - PreCoinRecord, + PlanchetRecord, RefreshSessionRecord, ReserveRecord, TipPlanchet, @@ -38,7 +38,7 @@ import { CryptoWorker } from "./cryptoWorker"; import { ContractTerms, PaybackRequest } from "../talerTypes"; -import { BenchmarkResult, CoinWithDenom, PayCoinInfo } from "../walletTypes"; +import { BenchmarkResult, CoinWithDenom, PayCoinInfo, PlanchetCreationResult } from "../walletTypes"; import * as timer from "../timer"; @@ -173,6 +173,7 @@ export class CryptoApi { */ wake(ws: WorkerState, work: WorkItem): void { if (this.stopped) { + console.log("cryptoApi is stopped"); CryptoApi.enableTracing && console.log("not waking, as cryptoApi is stopped"); return; } @@ -299,7 +300,6 @@ export class CryptoApi { priority: number, ...args: any[] ): Promise { - CryptoApi.enableTracing && console.log("cryptoApi: doRpc called"); const p: Promise = new Promise((resolve, reject) => { const rpcId = this.nextRpcId++; const workItem: WorkItem = { @@ -332,16 +332,14 @@ export class CryptoApi { throw Error("assertion failed"); }); - return p.then((r: T) => { - return r; - }); + return p; } - createPreCoin( + createPlanchet( denom: DenominationRecord, reserve: ReserveRecord, - ): Promise { - return this.doRpc("createPreCoin", 1, denom, reserve); + ): Promise { + return this.doRpc("createPlanchet", 1, denom, reserve); } createTipPlanchet(denom: DenominationRecord): Promise { diff --git a/src/crypto/cryptoImplementation.ts b/src/crypto/cryptoImplementation.ts index 9ffdec701..7cddf9031 100644 --- a/src/crypto/cryptoImplementation.ts +++ b/src/crypto/cryptoImplementation.ts @@ -28,8 +28,7 @@ import { CoinRecord, CoinStatus, DenominationRecord, - PreCoinRecord, - RefreshPreCoinRecord, + RefreshPlanchetRecord, RefreshSessionRecord, ReserveRecord, TipPlanchet, @@ -42,6 +41,7 @@ import { CoinWithDenom, PayCoinInfo, Timestamp, + PlanchetCreationResult, } from "../walletTypes"; import { canonicalJson, getTalerStampSec } from "../helpers"; import { AmountJson } from "../amounts"; @@ -154,10 +154,10 @@ export class CryptoImplementation { * Create a pre-coin of the given denomination to be withdrawn from then given * reserve. */ - createPreCoin( + createPlanchet( denom: DenominationRecord, reserve: ReserveRecord, - ): PreCoinRecord { + ): PlanchetCreationResult { const reservePub = decodeCrock(reserve.reservePub); const reservePriv = decodeCrock(reserve.reservePriv); const denomPub = decodeCrock(denom.denomPub); @@ -179,7 +179,7 @@ export class CryptoImplementation { const sig = eddsaSign(withdrawRequest, reservePriv); - const preCoin: PreCoinRecord = { + const planchet: PlanchetCreationResult = { blindingKey: encodeCrock(blindingFactor), coinEv: encodeCrock(ev), coinPriv: encodeCrock(coinKeyPair.eddsaPriv), @@ -188,11 +188,10 @@ export class CryptoImplementation { denomPub: encodeCrock(denomPub), denomPubHash: encodeCrock(denomPubHash), exchangeBaseUrl: reserve.exchangeBaseUrl, - isFromTip: false, reservePub: encodeCrock(reservePub), withdrawSig: encodeCrock(sig), }; - return preCoin; + return planchet; } /** @@ -424,7 +423,7 @@ export class CryptoImplementation { const transferPubs: string[] = []; const transferPrivs: string[] = []; - const preCoinsForGammas: RefreshPreCoinRecord[][] = []; + const planchetsForGammas: RefreshPlanchetRecord[][] = []; for (let i = 0; i < kappa; i++) { const transferKeyPair = createEcdheKeyPair(); @@ -442,7 +441,7 @@ export class CryptoImplementation { sessionHc.update(amountToBuffer(valueWithFee)); for (let i = 0; i < kappa; i++) { - const preCoins: RefreshPreCoinRecord[] = []; + const planchets: RefreshPlanchetRecord[] = []; for (let j = 0; j < newCoinDenoms.length; j++) { const transferPriv = decodeCrock(transferPrivs[i]); const oldCoinPub = decodeCrock(meltCoin.coinPub); @@ -456,16 +455,16 @@ export class CryptoImplementation { const pubHash = hash(coinPub); const denomPub = decodeCrock(newCoinDenoms[j].denomPub); const ev = rsaBlind(pubHash, blindingFactor, denomPub); - const preCoin: RefreshPreCoinRecord = { + const planchet: RefreshPlanchetRecord = { blindingKey: encodeCrock(blindingFactor), coinEv: encodeCrock(ev), privateKey: encodeCrock(coinPriv), publicKey: encodeCrock(coinPub), }; - preCoins.push(preCoin); + planchets.push(planchet); sessionHc.update(ev); } - preCoinsForGammas.push(preCoins); + planchetsForGammas.push(planchets); } const sessionHash = sessionHc.finish(); @@ -496,7 +495,7 @@ export class CryptoImplementation { newDenomHashes: newCoinDenoms.map(d => d.denomPubHash), newDenoms: newCoinDenoms.map(d => d.denomPub), norevealIndex: undefined, - preCoinsForGammas, + planchetsForGammas: planchetsForGammas, transferPrivs, transferPubs, valueOutput, diff --git a/src/crypto/primitives/kdf.ts b/src/crypto/primitives/kdf.ts index 082963074..e1baed408 100644 --- a/src/crypto/primitives/kdf.ts +++ b/src/crypto/primitives/kdf.ts @@ -88,5 +88,5 @@ export function kdf( output.set(chunk, i * 32); } - return output; + return output.slice(0, outputLength); } diff --git a/src/crypto/talerCrypto.ts b/src/crypto/talerCrypto.ts index b754b0c57..317b1af55 100644 --- a/src/crypto/talerCrypto.ts +++ b/src/crypto/talerCrypto.ts @@ -237,6 +237,9 @@ function rsaFullDomainHash(hm: Uint8Array, rsaPub: RsaPub): bigint.BigInteger { function rsaPubDecode(rsaPub: Uint8Array): RsaPub { const modulusLength = (rsaPub[0] << 8) | rsaPub[1]; const exponentLength = (rsaPub[2] << 8) | rsaPub[3]; + if (4 + exponentLength + modulusLength != rsaPub.length) { + throw Error("invalid RSA public key (format wrong)"); + } const modulus = rsaPub.slice(4, 4 + modulusLength); const exponent = rsaPub.slice( 4 + modulusLength, diff --git a/src/dbTypes.ts b/src/dbTypes.ts index bb4f5dbdf..8dba28edb 100644 --- a/src/dbTypes.ts +++ b/src/dbTypes.ts @@ -57,6 +57,13 @@ export enum ReserveRecordStatus { */ REGISTERING_BANK = "registering-bank", + /** + * We've registered reserve's information with the bank + * and are now waiting for the user to confirm the withdraw + * with the bank (typically 2nd factor auth). + */ + WAIT_CONFIRM_BANK = "wait-confirm-bank", + /** * Querying reserve status with the exchange. */ @@ -117,22 +124,26 @@ export interface ReserveRecord { timestampConfirmed: Timestamp | undefined; /** - * Current amount left in the reserve + * Amount that's still available for withdrawing + * from this reserve. */ - currentAmount: AmountJson | null; + withdrawRemainingAmount: AmountJson; + + /** + * Amount allocated for withdrawing. + * The corresponding withdraw operation may or may not + * have been completed yet. + */ + withdrawAllocatedAmount: AmountJson; + + withdrawCompletedAmount: AmountJson; /** * Amount requested when the reserve was created. * When a reserve is re-used (rare!) the current_amount can * be higher than the requested_amount */ - requestedAmount: AmountJson; - - /** - * What's the current amount that sits - * in precoins? - */ - precoinAmount: AmountJson; + initiallyRequestedAmount: AmountJson; /** * We got some payback to this reserve. We'll cease to automatically @@ -154,8 +165,19 @@ export interface ReserveRecord { bankWithdrawStatusUrl?: string; + /** + * URL that the bank gave us to redirect the customer + * to in order to confirm a withdrawal. + */ + bankWithdrawConfirmUrl?: string; + reserveStatus: ReserveRecordStatus; + /** + * Time of the last successful status query. + */ + lastStatusQuery: Timestamp | undefined; + lastError?: OperationError; } @@ -421,7 +443,16 @@ export interface ExchangeRecord { /** * A coin that isn't yet signed by an exchange. */ -export interface PreCoinRecord { +export interface PlanchetRecord { + withdrawSessionId: string; + /** + * Index of the coin in the withdrawal session. + */ + coinIndex: number; + + /** + * Public key of the coin. + */ coinPub: string; coinPriv: string; reservePub: string; @@ -443,7 +474,7 @@ export interface PreCoinRecord { /** * Planchet for a coin during refrehs. */ -export interface RefreshPreCoinRecord { +export interface RefreshPlanchetRecord { /** * Public key for the coin. */ @@ -485,6 +516,16 @@ export enum CoinStatus { * of the wallet database. */ export interface CoinRecord { + /** + * Withdraw session ID, or "" (empty string) if withdrawn via refresh. + */ + withdrawSessionId: string; + + /** + * Index of the coin in the withdrawal session. + */ + coinIndex: number; + /** * Public key of the coin. */ @@ -546,11 +587,17 @@ export interface CoinRecord { status: CoinStatus; } +export enum ProposalStatus { + PROPOSED = "proposed", + ACCEPTED = "accepted", + REJECTED = "rejected", +} + /** - * Proposal record, stored in the wallet's database. + * Record for a downloaded order, stored in the wallet's database. */ @Checkable.Class() -export class ProposalDownloadRecord { +export class ProposalRecord { /** * URL where the proposal was downloaded. */ @@ -576,10 +623,10 @@ export class ProposalDownloadRecord { contractTermsHash: string; /** - * Serial ID when the offer is stored in the wallet DB. + * Unique ID when the order is stored in the wallet DB. */ - @Checkable.Optional(Checkable.Number()) - id?: number; + @Checkable.String() + proposalId: string; /** * Timestamp (in ms) of when the record @@ -594,6 +641,9 @@ export class ProposalDownloadRecord { @Checkable.String() noncePriv: string; + @Checkable.String() + proposalStatus: ProposalStatus; + /** * Session ID we got when downloading the contract. */ @@ -604,7 +654,7 @@ export class ProposalDownloadRecord { * Verify that a value matches the schema of this class and convert it into a * member. */ - static checked: (obj: any) => ProposalDownloadRecord; + static checked: (obj: any) => ProposalRecord; } /** @@ -717,9 +767,9 @@ export interface RefreshSessionRecord { newDenoms: string[]; /** - * Precoins for each cut-and-choose instance. + * Planchets for each cut-and-choose instance. */ - preCoinsForGammas: RefreshPreCoinRecord[][]; + planchetsForGammas: RefreshPlanchetRecord[][]; /** * The transfer keys, kappa of them. @@ -933,7 +983,9 @@ export interface CoinsReturnRecord { wire: any; } -export interface WithdrawalRecord { +export interface WithdrawalSessionRecord { + withdrawSessionId: string; + /** * Reserve that we're withdrawing from. */ @@ -956,9 +1008,29 @@ export interface WithdrawalRecord { */ withdrawalAmount: string; - numCoinsTotal: number; + denoms: string[]; - numCoinsWithdrawn: number; + /** + * Coins in this session that are withdrawn are set to true. + */ + withdrawn: boolean[]; + + /** + * Coins in this session already have a planchet are set to true. + */ + planchetCreated: boolean[]; +} + +export interface BankWithdrawUriRecord { + /** + * The withdraw URI we got from the bank. + */ + talerWithdrawUri: string; + + /** + * Reserve that was created for the withdraw URI. + */ + reservePub: string; } /* tslint:disable:completed-docs */ @@ -967,7 +1039,7 @@ export interface WithdrawalRecord { * The stores and indices for the wallet database. */ export namespace Stores { - class ExchangeStore extends Store { + class ExchangesStore extends Store { constructor() { super("exchanges", { keyPath: "baseUrl" }); } @@ -988,16 +1060,18 @@ export namespace Stores { "denomPubIndex", "denomPub", ); + byWithdrawalWithIdx = new Index( + this, + "planchetsByWithdrawalWithIdxIndex", + ["withdrawSessionId", "coinIndex"], + ); } - class ProposalsStore extends Store { + class ProposalsStore extends Store { constructor() { - super("proposals", { - autoIncrement: true, - keyPath: "id", - }); + super("proposals", { keyPath: "proposalId" }); } - urlIndex = new Index( + urlIndex = new Index( this, "urlIndex", "url", @@ -1084,28 +1158,39 @@ export namespace Stores { } } - class WithdrawalsStore extends Store { + class WithdrawalSessionsStore extends Store { constructor() { - super("withdrawals", { keyPath: "id", autoIncrement: true }); + super("withdrawals", { keyPath: "withdrawSessionId" }); } - byReservePub = new Index( + byReservePub = new Index( this, "withdrawalsReservePubIndex", "reservePub", ); } - class PreCoinsStore extends Store { + class BankWithdrawUrisStore extends Store { constructor() { - super("precoins", { + super("bankWithdrawUris", { keyPath: "talerWithdrawUri" }); + } + } + + class PlanchetsStore extends Store { + constructor() { + super("planchets", { keyPath: "coinPub", }); } - byReservePub = new Index( + byReservePub = new Index( this, - "precoinsReservePubIndex", + "planchetsReservePubIndex", "reservePub", ); + byWithdrawalWithIdx = new Index( + this, + "planchetsByWithdrawalWithIdxIndex", + ["withdrawSessionId", "coinIndex"], + ); } export const coins = new CoinsStore(); @@ -1115,8 +1200,8 @@ export namespace Stores { export const config = new ConfigStore(); export const currencies = new CurrenciesStore(); export const denominations = new DenominationsStore(); - export const exchanges = new ExchangeStore(); - export const precoins = new PreCoinsStore(); + export const exchanges = new ExchangesStore(); + export const planchets = new PlanchetsStore(); export const proposals = new ProposalsStore(); export const refresh = new Store("refresh", { keyPath: "refreshSessionId", @@ -1125,7 +1210,8 @@ export namespace Stores { export const purchases = new PurchasesStore(); export const tips = new TipsStore(); export const senderWires = new SenderWiresStore(); - export const withdrawals = new WithdrawalsStore(); + export const withdrawalSession = new WithdrawalSessionsStore(); + export const bankWithdrawUris = new BankWithdrawUrisStore(); } /* tslint:enable:completed-docs */ diff --git a/src/headless/bank.ts b/src/headless/bank.ts index f35021003..36f61a71a 100644 --- a/src/headless/bank.ts +++ b/src/headless/bank.ts @@ -45,6 +45,37 @@ function makeId(length: number): string { export class Bank { constructor(private bankBaseUrl: string) {} + async generateWithdrawUri(bankUser: BankUser, amount: string): Promise { + const body = { + amount, + }; + + const reqUrl = new URI("api/withdraw-headless-uri") + .absoluteTo(this.bankBaseUrl) + .href(); + + const resp = await Axios({ + method: "post", + url: reqUrl, + data: body, + responseType: "json", + headers: { + "X-Taler-Bank-Username": bankUser.username, + "X-Taler-Bank-Password": bankUser.password, + }, + }); + + if (resp.status != 200) { + throw Error("failed to create bank reserve"); + } + + const withdrawUri = resp.data["taler_withdraw_uri"]; + if (!withdrawUri) { + throw Error("Bank's response did not include withdraw URI"); + } + return withdrawUri; + } + async createReserve( bankUser: BankUser, amount: string, diff --git a/src/headless/clk.ts b/src/headless/clk.ts index 4a568dc18..828eb24c0 100644 --- a/src/headless/clk.ts +++ b/src/headless/clk.ts @@ -29,6 +29,7 @@ export let STRING: Converter = new Converter(); export interface OptionArgs { help?: string; default?: T; + onPresentHandler?: (v: T) => void; } export interface ArgumentArgs { @@ -269,9 +270,6 @@ export class CommandGroup { } printHelp(progName: string, parents: CommandGroup[]) { - const chain: CommandGroup[] = Array.prototype.concat(parents, [ - this, - ]); let usageSpec = ""; for (let p of parents) { usageSpec += (p.name ?? progName) + " "; @@ -352,6 +350,7 @@ export class CommandGroup { process.exit(-1); throw Error("not reached"); } + foundOptions[d.name] = true; myArgs[d.name] = true; } else { if (r.value === undefined) { @@ -380,6 +379,7 @@ export class CommandGroup { } if (opt.isFlag) { myArgs[opt.name] = true; + foundOptions[opt.name] = true; } else { if (si == optShort.length - 1) { if (i === unparsedArgs.length - 1) { @@ -449,6 +449,13 @@ export class CommandGroup { } } + for (let option of this.options) { + const ph = option.args.onPresentHandler; + if (ph && foundOptions[option.name]) { + ph(myArgs[option.name]); + } + } + if (parsedArgs[this.argKey].help) { this.printHelp(progname, parents); process.exit(-1); @@ -546,7 +553,7 @@ export class Program { name: N, flagspec: string[], args: OptionArgs = {}, - ): Program> { + ): Program> { this.mainCommand.flag(name, flagspec, args); return this as any; } diff --git a/src/headless/helpers.ts b/src/headless/helpers.ts index a38ef1dbe..9faf24daf 100644 --- a/src/headless/helpers.ts +++ b/src/headless/helpers.ts @@ -34,35 +34,30 @@ import { Bank } from "./bank"; import fs = require("fs"); import { NodeCryptoWorkerFactory } from "../crypto/nodeProcessWorker"; +import { Logger } from "../logging"; + +const logger = new Logger("helpers.ts"); -const enableTracing = false; class ConsoleBadge implements Badge { startBusy(): void { - enableTracing && console.log("NOTIFICATION: busy"); } stopBusy(): void { - enableTracing && console.log("NOTIFICATION: busy end"); } showNotification(): void { - enableTracing && console.log("NOTIFICATION: show"); } clearNotification(): void { - enableTracing && console.log("NOTIFICATION: cleared"); } } export class NodeHttpLib implements HttpRequestLibrary { async get(url: string): Promise { - enableTracing && console.log("making GET request to", url); try { const resp = await Axios({ method: "get", url: url, responseType: "json", }); - enableTracing && console.log("got response", resp.data); - enableTracing && console.log("resp type", typeof resp.data); return { responseJson: resp.data, status: resp.status, @@ -76,7 +71,6 @@ export class NodeHttpLib implements HttpRequestLibrary { url: string, body: any, ): Promise { - enableTracing && console.log("making POST request to", url); try { const resp = await Axios({ method: "post", @@ -84,8 +78,6 @@ export class NodeHttpLib implements HttpRequestLibrary { responseType: "json", data: body, }); - enableTracing && console.log("got response", resp.data); - enableTracing && console.log("resp type", typeof resp.data); return { responseJson: resp.data, status: resp.status, @@ -149,7 +141,6 @@ export async function getDefaultNodeWallet( } myBackend.afterCommitCallback = async () => { - console.log("DATABASE COMMITTED"); // Allow caller to stop persisting the wallet. if (args.persistentStoragePath === undefined) { return; @@ -219,7 +210,7 @@ export async function withdrawTestBalance( const bankUser = await bank.registerRandomUser(); - console.log("bank user", bankUser); + logger.trace(`Registered bank user ${JSON.stringify(bankUser)}`) const exchangePaytoUri = await myWallet.getExchangePaytoUri( exchangeBaseUrl, @@ -234,6 +225,5 @@ export async function withdrawTestBalance( ); await myWallet.confirmReserve({ reservePub: reserveResponse.reservePub }); - await myWallet.runUntilReserveDepleted(reservePub); } diff --git a/src/headless/merchant.ts b/src/headless/merchant.ts index 889eb2d6a..423e3d09e 100644 --- a/src/headless/merchant.ts +++ b/src/headless/merchant.ts @@ -19,9 +19,9 @@ * Used mostly for integration tests. */ - /** - * Imports. - */ +/** + * Imports. + */ import axios from "axios"; import { CheckPaymentResponse } from "../talerTypes"; import URI = require("urijs"); @@ -30,10 +30,60 @@ import URI = require("urijs"); * Connection to the *internal* merchant backend. */ export class MerchantBackendConnection { - constructor( - public merchantBaseUrl: string, - public apiKey: string, - ) {} + async refund( + orderId: string, + reason: string, + refundAmount: string, + ): Promise { + const reqUrl = new URI("refund").absoluteTo(this.merchantBaseUrl).href(); + const refundReq = { + order_id: orderId, + reason, + refund: refundAmount, + }; + const resp = await axios({ + method: "post", + url: reqUrl, + data: refundReq, + responseType: "json", + headers: { + Authorization: `ApiKey ${this.apiKey}`, + }, + }); + if (resp.status != 200) { + throw Error("failed to do refund"); + } + console.log("response", resp.data); + const refundUri = resp.data.taler_refund_uri; + if (!refundUri) { + throw Error("no refund URI in response"); + } + return refundUri; + } + + constructor(public merchantBaseUrl: string, public apiKey: string) {} + + async authorizeTip(amount: string, justification: string) { + const reqUrl = new URI("tip-authorize").absoluteTo(this.merchantBaseUrl).href(); + const tipReq = { + amount, + justification, + }; + const resp = await axios({ + method: "post", + url: reqUrl, + data: tipReq, + responseType: "json", + headers: { + Authorization: `ApiKey ${this.apiKey}`, + }, + }); + const tipUri = resp.data.taler_tip_uri; + if (!tipUri) { + throw Error("response does not contain tip URI"); + } + return tipUri; + } async createOrder( amount: string, diff --git a/src/headless/taler-wallet-cli.ts b/src/headless/taler-wallet-cli.ts index 90c04dd97..cb2ff055c 100644 --- a/src/headless/taler-wallet-cli.ts +++ b/src/headless/taler-wallet-cli.ts @@ -26,11 +26,16 @@ import { BridgeIDBFactory, MemoryBackend } from "idb-bridge"; import { Logger } from "../logging"; import * as Amounts from "../amounts"; import { decodeCrock } from "../crypto/talerCrypto"; +import { Bank } from "./bank"; const logger = new Logger("taler-wallet-cli.ts"); const walletDbPath = os.homedir + "/" + ".talerwalletdb.json"; +function assertUnreachable(x: never): never { + throw new Error("Didn't expect to get here"); +} + async function doPay( wallet: Wallet, payUrl: string, @@ -78,7 +83,7 @@ async function doPay( } if (pay) { - const payRes = await wallet.confirmPay(result.proposalId!, undefined); + const payRes = await wallet.confirmPay(result.proposalId, undefined); console.log("paid!"); } else { console.log("not paying"); @@ -93,6 +98,12 @@ function applyVerbose(verbose: boolean) { } } +function printVersion() { + const info = require("../../../package.json"); + console.log(`${info.version}`); + process.exit(0); +} + const walletCli = clk .program("wallet", { help: "Command line interface for the GNU Taler wallet.", @@ -101,6 +112,9 @@ const walletCli = clk help: "Inhibit running certain operations, useful for debugging and testing.", }) + .flag("version", ["-v", "--version"], { + onPresentHandler: printVersion, + }) .flag("verbose", ["-V", "--verbose"], { help: "Enable verbose output.", }); @@ -133,12 +147,21 @@ async function withWallet( } walletCli - .subcommand("", "balance", { help: "Show wallet balance." }) + .subcommand("balance", "balance", { help: "Show wallet balance." }) + .flag("json", ["--json"], { + help: "Show raw JSON.", + }) .action(async args => { - console.log("balance command called"); await withWallet(args, async wallet => { const balance = await wallet.getBalances(); - console.log(JSON.stringify(balance, undefined, 2)); + if (args.balance.json) { + console.log(JSON.stringify(balance, undefined, 2)); + } else { + const currencies = Object.keys(balance.byCurrency).sort(); + for (const c of currencies) { + console.log(Amounts.toString(balance.byCurrency[c].available)); + } + } }); }); @@ -205,15 +228,8 @@ walletCli 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 res = await wallet.acceptWithdrawal(uri, selectedExchange); + await wallet.processReserve(res.reservePub); } }); }); @@ -258,13 +274,39 @@ const advancedCli = walletCli.subcommand("advancedArgs", "advanced", { advancedCli .subcommand("decode", "decode", { - help: "Decode base32-crockford", + help: "Decode base32-crockford.", }) .action(args => { - const enc = fs.readFileSync(0, 'utf8'); - fs.writeFileSync(1, decodeCrock(enc.trim())) + const enc = fs.readFileSync(0, "utf8"); + fs.writeFileSync(1, decodeCrock(enc.trim())); }); +advancedCli + .subcommand("payPrepare", "pay-prepare", { + help: "Claim an order but don't pay yet.", + }) + .requiredArgument("url", clk.STRING) + .action(async args => { + await withWallet(args, async wallet => { + const res = await wallet.preparePay(args.payPrepare.url); + switch (res.status) { + case "error": + console.log("error:", res.error); + break; + case "insufficient-balance": + console.log("insufficient balance"); + break; + case "paid": + console.log("already paid"); + break; + case "payment-possible": + console.log("payment possible"); + break; + default: + assertUnreachable(res); + } + }); + }); advancedCli .subcommand("refresh", "force-refresh", { @@ -288,7 +330,9 @@ advancedCli console.log(`coin ${coin.coinPub}`); console.log(` status ${coin.status}`); console.log(` exchange ${coin.exchangeBaseUrl}`); - console.log(` remaining amount ${Amounts.toString(coin.currentAmount)}`); + console.log( + ` remaining amount ${Amounts.toString(coin.currentAmount)}`, + ); } }); }); @@ -324,12 +368,11 @@ testCli return; } console.log("taler pay URI:", talerPayUri); - await withWallet(args, async (wallet) => { + await withWallet(args, async wallet => { await doPay(wallet, talerPayUri, { alwaysYes: true }); }); }); - testCli .subcommand("integrationtestCmd", "integrationtest", { help: "Run integration test with bank, exchange and merchant.", @@ -377,7 +420,74 @@ testCli }); testCli - .subcommand("testMerchantQrcodeCmd", "test-merchant-qrcode") + .subcommand("genTipUri", "gen-tip-uri", { + help: "Generate a taler://tip URI.", + }) + .requiredOption("amount", ["-a", "--amount"], clk.STRING, { + default: "TESTKUDOS:10", + }) + .action(async args => { + const merchantBackend = new MerchantBackendConnection( + "https://backend.test.taler.net/", + "sandbox", + ); + const tipUri = await merchantBackend.authorizeTip("TESTKUDOS:10", "test"); + console.log(tipUri); + }); + +testCli + .subcommand("genRefundUri", "gen-refund-uri", { + help: "Generate a taler://refund URI.", + }) + .requiredOption("amount", ["-a", "--amount"], clk.STRING, { + default: "TESTKUDOS:5", + }) + .requiredOption("refundAmount", ["-r", "--refund"], clk.STRING, { + default: "TESTKUDOS:3", + }) + .requiredOption("summary", ["-s", "--summary"], clk.STRING, { + default: "Test Payment (for refund)", + }) + .action(async args => { + const cmdArgs = args.genRefundUri; + 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; + } + await withWallet(args, async wallet => { + await doPay(wallet, talerPayUri, { alwaysYes: true }); + }); + const refundUri = await merchantBackend.refund( + orderResp.orderId, + "test refund", + cmdArgs.refundAmount, + ); + console.log(refundUri); + }); + +testCli + .subcommand("genPayUri", "gen-pay-uri", { + help: "Generate a taler://pay URI.", + }) + .flag("qrcode", ["--qr"], { + help: "Show a QR code with the taler://pay URI", + }) + .flag("wait", ["--wait"], { + help: "Wait until payment has completed", + }) .requiredOption("amount", ["-a", "--amount"], clk.STRING, { default: "TESTKUDOS:1", }) @@ -385,8 +495,7 @@ testCli default: "Test Payment", }) .action(async args => { - const cmdArgs = args.testMerchantQrcodeCmd; - applyVerbose(args.wallet.verbose); + const cmdArgs = args.genPayUri; console.log("creating order"); const merchantBackend = new MerchantBackendConnection( "https://backend.test.taler.net/", @@ -399,7 +508,6 @@ testCli ); 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"); @@ -407,18 +515,23 @@ testCli 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; + if (cmdArgs.qrcode) { + const qrcode = qrcodeGenerator(0, "M"); + qrcode.addData(talerPayUri); + qrcode.make(); + console.log(qrcode.createASCII()); + } + if (cmdArgs.wait) { + 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; + } } } }); diff --git a/src/wallet-test.ts b/src/wallet-test.ts index 86ddb5e73..fef11ae5d 100644 --- a/src/wallet-test.ts +++ b/src/wallet-test.ts @@ -47,6 +47,8 @@ function fakeCwd(current: string, value: string, feeDeposit: string): types.Coin denomSig: "(mock)", exchangeBaseUrl: "(mock)", reservePub: "(mock)", + coinIndex: -1, + withdrawSessionId: "", status: dbTypes.CoinStatus.Fresh, }, denom: { diff --git a/src/wallet.ts b/src/wallet.ts index f1d7be5e5..8fe8d367d 100644 --- a/src/wallet.ts +++ b/src/wallet.ts @@ -1,6 +1,6 @@ /* This file is part of TALER - (C) 2015 GNUnet e.V. + (C) 2015-2019 GNUnet e.V. TALER is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software @@ -58,19 +58,19 @@ import { DenominationRecord, DenominationStatus, ExchangeRecord, - PreCoinRecord, - ProposalDownloadRecord, + PlanchetRecord, + ProposalRecord, PurchaseRecord, - RefreshPreCoinRecord, + RefreshPlanchetRecord, RefreshSessionRecord, ReserveRecord, Stores, TipRecord, WireFee, - WithdrawalRecord, - ExchangeDetails, + WithdrawalSessionRecord, ExchangeUpdateStatus, ReserveRecordStatus, + ProposalStatus, } from "./dbTypes"; import { Auditor, @@ -128,14 +128,15 @@ import { parseTipUri, parseRefundUri, } from "./taleruri"; -import { isFirefox } from "./webex/compat"; import { Logger } from "./logging"; +import { randomBytes } from "./crypto/primitives/nacl-fast"; +import { encodeCrock, getRandomBytes } from "./crypto/talerCrypto"; interface SpeculativePayData { payCoinInfo: PayCoinInfo; exchangeUrl: string; - proposalId: number; - proposal: ProposalDownloadRecord; + orderDownloadId: string; + proposal: ProposalRecord; } /** @@ -166,7 +167,7 @@ const builtinCurrencies: CurrencyRecord[] = [ function isWithdrawableDenom(d: DenominationRecord) { const now = getTimestampNow(); const started = now.t_ms >= d.stampStart.t_ms; - const stillOkay = d.stampExpireWithdraw.t_ms + (60 * 1000) > now.t_ms; + const stillOkay = d.stampExpireWithdraw.t_ms + 60 * 1000 > now.t_ms; return started && stillOkay; } @@ -175,6 +176,10 @@ interface SelectPayCoinsResult { totalFees: AmountJson; } +function assertUnreachable(x: never): never { + throw new Error("Didn't expect to get here"); +} + /** * Get the amount that we lose when refreshing a coin of the given denomination * with a certain amount left. @@ -353,6 +358,43 @@ export class OperationFailedAndReportedError extends Error { const logger = new Logger("wallet.ts"); +interface MemoEntry { + p: Promise; + t: number; + n: number; +} + +class AsyncOpMemo { + n = 0; + memo: { [k: string]: MemoEntry } = {}; + put(key: string, p: Promise): Promise { + const n = this.n++; + this.memo[key] = { + p, + n, + t: new Date().getTime(), + }; + p.finally(() => { + const r = this.memo[key]; + if (r && r.n === n) { + delete this.memo[key]; + } + }); + return p; + } + find(key: string): Promise | undefined { + const res = this.memo[key]; + const tNow = new Date().getTime(); + if (res && res.t < tNow - 10 * 1000) { + delete this.memo[key]; + return; + } else if (res) { + return res.p; + } + return; + } +} + /** * The platform-independent wallet implementation. */ @@ -369,6 +411,8 @@ export class Wallet { private speculativePayData: SpeculativePayData | undefined; private cachedNextUrl: { [fulfillmentUrl: string]: NextUrlResult } = {}; + private memoProcessReserve = new AsyncOpMemo(); + constructor( db: IDBDatabase, http: HttpRequestLibrary, @@ -383,33 +427,52 @@ export class Wallet { this.cryptoApi = new CryptoApi(cryptoWorkerFactory); } + /** + * Execute one operation based on the pending operation info record. + */ + async processOnePendingOperation( + pending: PendingOperationInfo, + ): Promise { + switch (pending.type) { + case "bug": + return; + case "dirty-coin": + await this.refresh(pending.coinPub); + break; + case "exchange-update": + await this.updateExchangeFromUrl(pending.exchangeBaseUrl); + break; + case "planchet": + await this.processPlanchet(pending.coinPub); + break; + case "refresh": + await this.processRefreshSession(pending.refreshSessionId); + break; + case "reserve": + await this.processReserve(pending.reservePub); + break; + case "withdraw": + await this.processWithdrawSession(pending.withdrawSessionId); + break; + case "proposal": + // Nothing to do, user needs to accept/reject + break; + default: + assertUnreachable(pending); + } + } + /** * Process pending operations. */ public async runPending(): Promise { - // FIXME: maybe prioritize pending operations by their urgency? - const exchangeBaseUrlList = await oneShotIter( - this.db, - Stores.exchanges, - ).map(x => x.baseUrl); - - 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); + const pendingOpsResponse = await this.getPendingOperations(); + for (const p of pendingOpsResponse.pendingOperations) { + try { + await this.processOnePendingOperation(p); + } catch (e) { + console.error(e); + } } } @@ -427,29 +490,23 @@ export class Wallet { */ 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); + const r = await this.getPendingOperations(); + const allPending = r.pendingOperations; + const relevantPending = allPending.filter(x => { + switch (x.type) { + case "planchet": + case "reserve": + return x.reservePub === reservePub; + default: + return false; } - break; + }); + if (relevantPending.length === 0) { + return; + } + for (const p of relevantPending) { + await this.processOnePendingOperation(p); } - await this.processReserve(reservePub); } } @@ -478,18 +535,6 @@ export class Wallet { ); } - async updateExchanges(): Promise { - const exchangeUrls = await oneShotIter(this.db, Stores.exchanges).map( - e => e.baseUrl, - ); - - for (const url of exchangeUrls) { - this.updateExchangeFromUrl(url).catch(e => { - console.error("updating exchange failed", e); - }); - } - } - private async getCoinsForReturn( exchangeBaseUrl: string, amount: AmountJson, @@ -554,8 +599,6 @@ export class Wallet { cds.push({ coin, denom }); } - console.log("coin return: selecting from possible coins", { cds, amount }); - const res = selectPayCoins(denoms, cds, amount, amount); if (res) { return res.cds; @@ -711,7 +754,7 @@ export class Wallet { * pay for a proposal in the wallet's database. */ private async recordConfirmPay( - proposal: ProposalDownloadRecord, + proposal: ProposalRecord, payCoinInfo: PayCoinInfo, chosenExchange: string, ): Promise { @@ -774,7 +817,7 @@ export class Wallet { }; } - let proposalId: number; + let proposalId: string; try { proposalId = await this.downloadProposal( uriResult.downloadUrl, @@ -788,7 +831,7 @@ export class Wallet { } const proposal = await this.getProposal(proposalId); if (!proposal) { - throw Error("could not get proposal"); + throw Error(`could not get proposal ${proposalId}`); } console.log("proposal", proposal); @@ -868,7 +911,7 @@ export class Wallet { return { status: "insufficient-balance", contractTerms: proposal.contractTerms, - proposalId: proposal.id!, + proposalId: proposal.proposalId, }; } @@ -876,7 +919,7 @@ export class Wallet { if ( !this.speculativePayData || (this.speculativePayData && - this.speculativePayData.proposalId !== proposalId) + this.speculativePayData.orderDownloadId !== proposalId) ) { const { exchangeUrl, cds, totalAmount } = res; const payCoinInfo = await this.cryptoApi.signDeposit( @@ -888,7 +931,7 @@ export class Wallet { exchangeUrl, payCoinInfo, proposal, - proposalId, + orderDownloadId: proposalId, }; Wallet.enableTracing && console.log("created speculative pay data for payment"); @@ -897,7 +940,7 @@ export class Wallet { return { status: "payment-possible", contractTerms: proposal.contractTerms, - proposalId: proposal.id!, + proposalId: proposal.proposalId, totalFees: res.totalFees, }; } @@ -920,14 +963,14 @@ export class Wallet { * @param sessionId Current session ID, if the proposal is being * downloaded in the context of a session ID. */ - async downloadProposal(url: string, sessionId?: string): Promise { + async downloadProposal(url: string, sessionId?: string): Promise { const oldProposal = await oneShotGetIndexed( this.db, Stores.proposals.urlIndex, url, ); if (oldProposal) { - return oldProposal.id!; + return oldProposal.proposalId; } const { priv, pub } = await this.cryptoApi.createEddsaKeypair(); @@ -946,7 +989,9 @@ export class Wallet { const contractTermsHash = await this.hashContract(proposal.contract_terms); - const proposalRecord: ProposalDownloadRecord = { + const proposalId = encodeCrock(getRandomBytes(32)); + + const proposalRecord: ProposalRecord = { contractTerms: proposal.contract_terms, contractTermsHash, merchantSig: proposal.sig, @@ -954,14 +999,13 @@ export class Wallet { timestamp: getTimestampNow(), url, downloadSessionId: sessionId, + proposalId: proposalId, + proposalStatus: ProposalStatus.PROPOSED, }; - - const id = await oneShotPut(this.db, Stores.proposals, proposalRecord); + await oneShotPut(this.db, Stores.proposals, proposalRecord); this.notifier.notify(); - if (typeof id !== "number") { - throw Error("db schema wrong"); - } - return id; + + return proposalId; } async refundFailedPay(proposalId: number) { @@ -1091,7 +1135,7 @@ export class Wallet { * Add a contract to the wallet and sign coins, and send them. */ async confirmPay( - proposalId: number, + proposalId: string, sessionIdOverride: string | undefined, ): Promise { Wallet.enableTracing && @@ -1175,13 +1219,13 @@ export class Wallet { * Get the speculative pay data, but only if coins have not changed in between. */ async getSpeculativePayData( - proposalId: number, + proposalId: string, ): Promise { const sp = this.speculativePayData; if (!sp) { return; } - if (sp.proposalId !== proposalId) { + if (sp.orderDownloadId !== proposalId) { return; } const coinKeys = sp.payCoinInfo.updatedCoins.map(x => x.coinPub); @@ -1209,58 +1253,104 @@ export class Wallet { return sp; } - /** - * Send reserve details to the bank. - */ - private async sendReserveInfoToBank(reservePub: string) { - const reserve = await oneShotGet(this.db, Stores.reserves, reservePub); - if (!reserve) { - throw Error("reserve not in db"); + private async processReserveBankStatus(reservePub: string): Promise { + let reserve = await oneShotGet(this.db, Stores.reserves, reservePub); + switch (reserve?.reserveStatus) { + case ReserveRecordStatus.WAIT_CONFIRM_BANK: + case ReserveRecordStatus.REGISTERING_BANK: + break; + default: + return; } - - if (reserve.reserveStatus != ReserveRecordStatus.REGISTERING_BANK) { + const bankStatusUrl = reserve.bankWithdrawStatusUrl; + if (!bankStatusUrl) { return; } - const bankStatusUrl = reserve.bankWithdrawStatusUrl; - if (!bankStatusUrl) { - throw Error("no bank withdraw status URL available."); - } - - const now = getTimestampNow(); - let status; + let status: WithdrawOperationStatusResponse; try { const statusResp = await this.http.get(bankStatusUrl); status = WithdrawOperationStatusResponse.checked(statusResp.responseJson); } catch (e) { - console.log("bank error response", e); throw e; } + if (status.selection_done) { + if (reserve.reserveStatus === ReserveRecordStatus.REGISTERING_BANK) { + await this.registerReserveWithBank(reservePub); + return await this.processReserveBankStatus(reservePub); + } + } else { + await this.registerReserveWithBank(reservePub); + return await this.processReserveBankStatus(reservePub); + } + if (status.transfer_done) { await oneShotMutate(this.db, Stores.reserves, reservePub, r => { + switch (r.reserveStatus) { + case ReserveRecordStatus.REGISTERING_BANK: + case ReserveRecordStatus.WAIT_CONFIRM_BANK: + break; + default: + return; + } + const now = getTimestampNow(); r.timestampConfirmed = now; + r.reserveStatus = ReserveRecordStatus.QUERYING_STATUS; return r; }); - } else if (reserve.timestampReserveInfoPosted === undefined) { - try { - if (!status.selection_done) { - const bankResp = await this.http.postJson(bankStatusUrl, { - reserve_pub: reservePub, - selected_exchange: reserve.exchangeWire, - }); - } - } catch (e) { - console.log("bank error response", e); - throw e; - } + await this.processReserveImpl(reservePub); + } else { await oneShotMutate(this.db, Stores.reserves, reservePub, r => { - r.timestampReserveInfoPosted = now; + switch (r.reserveStatus) { + case ReserveRecordStatus.WAIT_CONFIRM_BANK: + break; + default: + return; + } + r.bankWithdrawConfirmUrl = status.confirm_transfer_url; return r; }); } } + async registerReserveWithBank(reservePub: string) { + let reserve = await oneShotGet(this.db, Stores.reserves, reservePub); + switch (reserve?.reserveStatus) { + case ReserveRecordStatus.WAIT_CONFIRM_BANK: + case ReserveRecordStatus.REGISTERING_BANK: + break; + default: + return; + } + const bankStatusUrl = reserve.bankWithdrawStatusUrl; + if (!bankStatusUrl) { + return; + } + console.log("making selection"); + if (reserve.timestampReserveInfoPosted) { + throw Error("bank claims that reserve info selection is not done"); + } + const bankResp = await this.http.postJson(bankStatusUrl, { + reserve_pub: reservePub, + selected_exchange: reserve.exchangeWire, + }); + console.log("got response", bankResp); + await oneShotMutate(this.db, Stores.reserves, reservePub, r => { + switch (r.reserveStatus) { + case ReserveRecordStatus.REGISTERING_BANK: + case ReserveRecordStatus.WAIT_CONFIRM_BANK: + break; + default: + return; + } + r.timestampReserveInfoPosted = getTimestampNow(); + r.reserveStatus = ReserveRecordStatus.WAIT_CONFIRM_BANK; + return r; + }); + return this.processReserveBankStatus(reservePub); + } + /** * First fetch information requred to withdraw from the reserve, * then deplete the reserve, withdrawing coins until it is empty. @@ -1269,6 +1359,18 @@ export class Wallet { * state DORMANT. */ async processReserve(reservePub: string): Promise { + const p = this.memoProcessReserve.find(reservePub); + if (p) { + return p; + } else { + return this.memoProcessReserve.put( + reservePub, + this.processReserveImpl(reservePub), + ); + } + } + + private async processReserveImpl(reservePub: string): Promise { const reserve = await oneShotGet(this.db, Stores.reserves, reservePub); if (!reserve) { console.log("not processing reserve: reserve does not exist"); @@ -1282,19 +1384,23 @@ export class Wallet { // nothing to do break; case ReserveRecordStatus.REGISTERING_BANK: - await this.sendReserveInfoToBank(reservePub); - return this.processReserve(reservePub); + await this.processReserveBankStatus(reservePub); + return this.processReserveImpl(reservePub); case ReserveRecordStatus.QUERYING_STATUS: await this.updateReserve(reservePub); - return this.processReserve(reservePub); + return this.processReserveImpl(reservePub); case ReserveRecordStatus.WITHDRAWING: await this.depleteReserve(reservePub); break; case ReserveRecordStatus.DORMANT: // nothing to do break; + case ReserveRecordStatus.WAIT_CONFIRM_BANK: + await this.processReserveBankStatus(reservePub); + break; default: console.warn("unknown reserve record status:", reserve.reserveStatus); + assertUnreachable(reserve.reserveStatus); break; } } @@ -1302,38 +1408,38 @@ export class Wallet { /** * Given a planchet, withdraw a coin from the exchange. */ - private async processPreCoin(preCoinPub: string): Promise { - console.log("processPreCoin", preCoinPub); - const preCoin = await oneShotGet(this.db, Stores.precoins, preCoinPub); - if (!preCoin) { - console.log("processPreCoin: preCoinPub not found"); + private async processPlanchet(coinPub: string): Promise { + logger.trace("process planchet", coinPub); + const planchet = await oneShotGet(this.db, Stores.planchets, coinPub); + if (!planchet) { + console.log("processPlanchet: planchet not found"); return; } const exchange = await oneShotGet( this.db, Stores.exchanges, - preCoin.exchangeBaseUrl, + planchet.exchangeBaseUrl, ); if (!exchange) { - console.error("db inconsistent: exchange for precoin not found"); + console.error("db inconsistent: exchange for planchet not found"); return; } const denom = await oneShotGet(this.db, Stores.denominations, [ - preCoin.exchangeBaseUrl, - preCoin.denomPub, + planchet.exchangeBaseUrl, + planchet.denomPub, ]); if (!denom) { - console.error("db inconsistent: denom for precoin not found"); + console.error("db inconsistent: denom for planchet not found"); return; } const wd: any = {}; - wd.denom_pub_hash = preCoin.denomPubHash; - wd.reserve_pub = preCoin.reservePub; - wd.reserve_sig = preCoin.withdrawSig; - wd.coin_ev = preCoin.coinEv; + wd.denom_pub_hash = planchet.denomPubHash; + wd.reserve_pub = planchet.reservePub; + wd.reserve_sig = planchet.withdrawSig; + wd.coin_ev = planchet.coinEv; const reqUrl = new URI("reserve/withdraw").absoluteTo(exchange.baseUrl); const resp = await this.http.postJson(reqUrl.href(), wd); @@ -1341,51 +1447,60 @@ export class Wallet { const denomSig = await this.cryptoApi.rsaUnblind( r.ev_sig, - preCoin.blindingKey, - preCoin.denomPub, + planchet.blindingKey, + planchet.denomPub, ); const coin: CoinRecord = { - blindingKey: preCoin.blindingKey, - coinPriv: preCoin.coinPriv, - coinPub: preCoin.coinPub, - currentAmount: preCoin.coinValue, - denomPub: preCoin.denomPub, - denomPubHash: preCoin.denomPubHash, + blindingKey: planchet.blindingKey, + coinPriv: planchet.coinPriv, + coinPub: planchet.coinPub, + currentAmount: planchet.coinValue, + denomPub: planchet.denomPub, + denomPubHash: planchet.denomPubHash, denomSig, - exchangeBaseUrl: preCoin.exchangeBaseUrl, - reservePub: preCoin.reservePub, + exchangeBaseUrl: planchet.exchangeBaseUrl, + reservePub: planchet.reservePub, status: CoinStatus.Fresh, - }; - - const mutateReserve = (r: ReserveRecord) => { - const x = Amounts.sub( - r.precoinAmount, - preCoin.coinValue, - denom.feeWithdraw, - ); - if (x.saturated) { - // FIXME!!!! - console.error("database inconsistent"); - throw TransactionAbort; - } - r.precoinAmount = x.amount; - return r; + coinIndex: planchet.coinIndex, + withdrawSessionId: planchet.withdrawSessionId, }; await runWithWriteTransaction( this.db, - [Stores.reserves, Stores.precoins, Stores.coins], + [Stores.planchets, Stores.coins, Stores.withdrawalSession, Stores.reserves], async tx => { - const currentPc = await tx.get(Stores.precoins, coin.coinPub); + const currentPc = await tx.get(Stores.planchets, coin.coinPub); if (!currentPc) { return; } - await tx.mutate(Stores.reserves, preCoin.reservePub, mutateReserve); - await tx.delete(Stores.precoins, coin.coinPub); + const ws = await tx.get( + Stores.withdrawalSession, + planchet.withdrawSessionId, + ); + if (!ws) { + return; + } + if (ws.withdrawn[planchet.coinIndex]) { + // Already withdrawn + return; + } + ws.withdrawn[planchet.coinIndex] = true; + await tx.put(Stores.withdrawalSession, ws); + const r = await tx.get(Stores.reserves, planchet.reservePub); + if (!r) { + return; + } + r.withdrawCompletedAmount = Amounts.add( + r.withdrawCompletedAmount, + Amounts.add(denom.value, denom.feeWithdraw).amount, + ).amount; + tx.put(Stores.reserves, r); + await tx.delete(Stores.planchets, coin.coinPub); await tx.add(Stores.coins, coin); }, ); + this.notifier.notify(); logger.trace(`withdraw of one coin ${coin.coinPub} finished`); } @@ -1409,13 +1524,16 @@ export class Wallet { reserveStatus = ReserveRecordStatus.UNCONFIRMED; } + const currency = req.amount.currency; + const reserveRecord: ReserveRecord = { created: now, - currentAmount: null, + withdrawAllocatedAmount: Amounts.getZero(currency), + withdrawCompletedAmount: Amounts.getZero(currency), + withdrawRemainingAmount: Amounts.getZero(currency), exchangeBaseUrl: canonExchange, hasPayback: false, - precoinAmount: Amounts.getZero(req.amount.currency), - requestedAmount: req.amount, + initiallyRequestedAmount: req.amount, reservePriv: keypair.priv, reservePub: keypair.pub, senderWire: req.senderWire, @@ -1424,6 +1542,7 @@ export class Wallet { bankWithdrawStatusUrl: req.bankWithdrawStatusUrl, exchangeWire: req.exchangeWire, reserveStatus, + lastStatusQuery: undefined, }; const senderWire = req.senderWire; @@ -1463,24 +1582,50 @@ export class Wallet { const cr: CurrencyRecord = currencyRecord; - await runWithWriteTransaction( + const resp = await runWithWriteTransaction( this.db, - [Stores.currencies, Stores.reserves], + [Stores.currencies, Stores.reserves, Stores.bankWithdrawUris], async tx => { + // Check if we have already created a reserve for that bankWithdrawStatusUrl + if (reserveRecord.bankWithdrawStatusUrl) { + const bwi = await tx.get( + Stores.bankWithdrawUris, + reserveRecord.bankWithdrawStatusUrl, + ); + if (bwi) { + const otherReserve = await tx.get(Stores.reserves, bwi.reservePub); + if (otherReserve) { + logger.trace( + "returning existing reserve for bankWithdrawStatusUri", + ); + return { + exchange: otherReserve.exchangeBaseUrl, + reservePub: otherReserve.reservePub, + }; + } + } + await tx.put(Stores.bankWithdrawUris, { + reservePub: reserveRecord.reservePub, + talerWithdrawUri: reserveRecord.bankWithdrawStatusUrl, + }); + } await tx.put(Stores.currencies, cr); await tx.put(Stores.reserves, reserveRecord); + const r: CreateReserveResponse = { + exchange: canonExchange, + reservePub: keypair.pub, + }; + return r; }, ); - this.processReserve(keypair.pub).catch(e => { + // Asynchronously process the reserve, but return + // to the caller already. + this.processReserve(resp.reservePub).catch(e => { console.error("Processing reserve failed:", e); }); - const r: CreateReserveResponse = { - exchange: canonExchange, - reservePub: keypair.pub, - }; - return r; + return resp; } /** @@ -1526,15 +1671,15 @@ export class Wallet { } logger.trace(`depleting reserve ${reservePub}`); - const withdrawAmount = reserve.currentAmount; - if (!withdrawAmount) { - throw Error("BUG: reserveStatus=WITHDRAWING, but currentAmount is empty"); - } + const withdrawAmount = reserve.withdrawRemainingAmount; + + logger.trace(`getting denom list`); const denomsForWithdraw = await this.getVerifiedWithdrawDenomList( reserve.exchangeBaseUrl, withdrawAmount, ); + logger.trace(`got denom list`); if (denomsForWithdraw.length === 0) { const m = `Unable to withdraw from reserve, no denominations are available to withdraw.`; await this.setReserveError(reserve.reservePub, { @@ -1542,23 +1687,24 @@ export class Wallet { message: m, details: {}, }); + console.log(m); throw new OperationFailedAndReportedError(m); } - const withdrawalRecord: WithdrawalRecord = { + logger.trace("selected denominations"); + + const withdrawalSessionId = encodeCrock(randomBytes(32)); + + const withdrawalRecord: WithdrawalSessionRecord = { + withdrawSessionId: withdrawalSessionId, reservePub: reserve.reservePub, withdrawalAmount: Amounts.toString(withdrawAmount), startTimestamp: getTimestampNow(), - numCoinsTotal: denomsForWithdraw.length, - numCoinsWithdrawn: 0, + denoms: denomsForWithdraw.map(x => x.denomPub), + withdrawn: denomsForWithdraw.map(x => false), + planchetCreated: denomsForWithdraw.map(x => false), }; - const preCoinRecords: PreCoinRecord[] = await Promise.all( - denomsForWithdraw.map(async denom => { - return await this.cryptoApi.createPreCoin(denom, reserve); - }), - ); - const totalCoinValue = Amounts.sum(denomsForWithdraw.map(x => x.value)) .amount; const totalCoinWithdrawFee = Amounts.sum( @@ -1570,20 +1716,24 @@ export class Wallet { ).amount; function mutateReserve(r: ReserveRecord): ReserveRecord { - const currentAmount = r.currentAmount; - if (!currentAmount) { - throw Error("can't withdraw when amount is unknown"); - } - r.precoinAmount = Amounts.add( - r.precoinAmount, + const remaining = Amounts.sub( + r.withdrawRemainingAmount, totalWithdrawAmount, - ).amount; - const result = Amounts.sub(currentAmount, totalWithdrawAmount); - if (result.saturated) { - console.error("can't create precoins, saturated"); + ); + if (remaining.saturated) { + console.error("can't create planchets, saturated"); throw TransactionAbort; } - r.currentAmount = result.amount; + const allocated = Amounts.add( + r.withdrawAllocatedAmount, + totalWithdrawAmount, + ); + if (allocated.saturated) { + console.error("can't create planchets, saturated"); + throw TransactionAbort; + } + r.withdrawRemainingAmount = remaining.amount; + r.withdrawAllocatedAmount = allocated.amount; r.reserveStatus = ReserveRecordStatus.DORMANT; return r; @@ -1591,7 +1741,7 @@ export class Wallet { const success = await runWithWriteTransaction( this.db, - [Stores.precoins, Stores.withdrawals, Stores.reserves], + [Stores.planchets, Stores.withdrawalSession, Stores.reserves], async tx => { const myReserve = await tx.get(Stores.reserves, reservePub); if (!myReserve) { @@ -1600,20 +1750,113 @@ export class Wallet { 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); + await tx.put(Stores.withdrawalSession, withdrawalRecord); return true; }, ); if (success) { - logger.trace(`withdrawing ${preCoinRecords.length} coins`); - for (let x of preCoinRecords) { - await this.processPreCoin(x.coinPub); + console.log("processing new withdraw session"); + await this.processWithdrawSession(withdrawalSessionId); + } else { + console.trace("withdraw session already existed"); + } + } + + private async processWithdrawSession(withdrawalSessionId: string): Promise { + logger.trace("processing withdraw session", withdrawalSessionId); + const ws = await oneShotGet( + this.db, + Stores.withdrawalSession, + withdrawalSessionId, + ); + if (!ws) { + logger.trace("withdraw session doesn't exist"); + return; + } + + const ps = ws.denoms.map((d, i) => + this.processWithdrawCoin(withdrawalSessionId, i), + ); + await Promise.all(ps); + this.badge.showNotification(); + return; + } + + private async processWithdrawCoin( + withdrawalSessionId: string, + coinIndex: number, + ) { + logger.info("starting withdraw for coin"); + const ws = await oneShotGet( + this.db, + Stores.withdrawalSession, + withdrawalSessionId, + ); + if (!ws) { + console.log("ws doesn't exist"); + return; + } + + const coin = await oneShotGetIndexed( + this.db, + Stores.coins.byWithdrawalWithIdx, + [withdrawalSessionId, coinIndex], + ); + + if (coin) { + console.log("coin already exists"); + return; + } + + const pc = await oneShotGetIndexed( + this.db, + Stores.planchets.byWithdrawalWithIdx, + [withdrawalSessionId, coinIndex], + ); + + if (pc) { + return this.processPlanchet(pc.coinPub); + } else { + const reserve = await oneShotGet(this.db, Stores.reserves, ws.reservePub); + if (!reserve) { + return; } + const denom = await oneShotGet(this.db, Stores.denominations, [ + reserve.exchangeBaseUrl, + ws.denoms[coinIndex], + ]); + if (!denom) { + return; + } + const r = await this.cryptoApi.createPlanchet(denom, reserve); + const newPlanchet: PlanchetRecord = { + blindingKey: r.blindingKey, + coinEv: r.coinEv, + coinIndex, + coinPriv: r.coinPriv, + coinPub: r.coinPub, + coinValue: r.coinValue, + denomPub: r.denomPub, + denomPubHash: r.denomPubHash, + exchangeBaseUrl: r.exchangeBaseUrl, + isFromTip: false, + reservePub: r.reservePub, + withdrawSessionId: withdrawalSessionId, + withdrawSig: r.withdrawSig, + }; + await runWithWriteTransaction(this.db, [Stores.planchets, Stores.withdrawalSession], async (tx) => { + const myWs = await tx.get(Stores.withdrawalSession, withdrawalSessionId); + if (!myWs) { + return; + } + if (myWs.planchetCreated[coinIndex]) { + return; + } + await tx.put(Stores.planchets, newPlanchet); + }); + await this.processPlanchet(newPlanchet.coinPub); } } @@ -1644,7 +1887,6 @@ export class Wallet { 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; @@ -1657,15 +1899,40 @@ export class Wallet { } } const reserveInfo = ReserveStatus.checked(resp.responseJson); + const balance = 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; + + // FIXME: check / compare history! + if (!r.lastStatusQuery) { + // FIXME: check if this matches initial expectations + r.withdrawRemainingAmount = balance; + } else { + const expectedBalance = Amounts.sub( + r.withdrawAllocatedAmount, + r.withdrawCompletedAmount, + ); + const cmp = Amounts.cmp(balance, expectedBalance.amount); + if (cmp == 0) { + // Nothing changed. + return; + } + if (cmp > 0) { + const extra = Amounts.sub(balance, expectedBalance.amount).amount; + r.withdrawRemainingAmount = Amounts.add( + r.withdrawRemainingAmount, + extra, + ).amount; + } else { + // We're missing some money. + } + } + r.lastStatusQuery = getTimestampNow(); + r.reserveStatus = ReserveRecordStatus.WITHDRAWING; return r; }); - await oneShotPut(this.db, Stores.reserves, reserve); this.notifier.notify(); } @@ -1752,15 +2019,21 @@ export class Wallet { exchangeBaseUrl, ); if (!exchange) { + console.log("exchange not found"); throw Error(`exchange ${exchangeBaseUrl} not found`); } const exchangeDetails = exchange.details; if (!exchangeDetails) { + console.log("exchange details not available"); throw Error(`exchange ${exchangeBaseUrl} details not available`); } + console.log("getting possible denoms"); + const possibleDenoms = await this.getPossibleDenoms(exchange.baseUrl); + console.log("got possible denoms"); + let allValid = false; let selectedDenoms: DenominationRecord[]; @@ -1769,12 +2042,15 @@ export class Wallet { allValid = true; const nextPossibleDenoms = []; selectedDenoms = getWithdrawDenomList(amount, possibleDenoms); + console.log("got withdraw denom list"); for (const denom of selectedDenoms || []) { if (denom.status === DenominationStatus.Unverified) { + console.log("checking validity", denom, exchangeDetails.masterPublicKey); const valid = await this.cryptoApi.isValidDenom( denom, exchangeDetails.masterPublicKey, ); + console.log("done checking validity"); if (!valid) { denom.status = DenominationStatus.VerifiedBad; allValid = false; @@ -1789,6 +2065,8 @@ export class Wallet { } } while (selectedDenoms.length > 0 && !allValid); + console.log("returning denoms"); + return selectedDenoms; } @@ -1958,11 +2236,9 @@ export class Wallet { exchangeBaseUrl: string, supportedTargetTypes: string[], ): Promise { - const exchangeRecord = await oneShotGet( - this.db, - Stores.exchanges, - exchangeBaseUrl, - ); + // We do the update here, since the exchange might not even exist + // yet in our database. + const exchangeRecord = await this.updateExchangeFromUrl(exchangeBaseUrl); if (!exchangeRecord) { throw Error(`Exchange '${exchangeBaseUrl}' not found.`); } @@ -2347,34 +2623,6 @@ export class Wallet { ); }); - await tx.iter(Stores.reserves).forEach(r => { - if (!r.timestampConfirmed) { - return; - } - 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.exchangeBaseUrl, - ); - }); - - await tx.iter(Stores.reserves).forEach(r => { - if (!r.hasPayback) { - return; - } - addTo( - balanceStore, - "paybackAmount", - r.currentAmount!, - r.exchangeBaseUrl, - ); - return balanceStore; - }); - await tx.iter(Stores.purchases).forEach(t => { if (t.finished) { return; @@ -2598,8 +2846,8 @@ export class Wallet { const privs = Array.from(refreshSession.transferPrivs); privs.splice(norevealIndex, 1); - const preCoins = refreshSession.preCoinsForGammas[norevealIndex]; - if (!preCoins) { + const planchets = refreshSession.planchetsForGammas[norevealIndex]; + if (!planchets) { throw Error("refresh index error"); } @@ -2612,7 +2860,7 @@ export class Wallet { throw Error("inconsistent database"); } - const evs = preCoins.map((x: RefreshPreCoinRecord) => x.coinEv); + const evs = planchets.map((x: RefreshPlanchetRecord) => x.coinEv); const linkSigs: string[] = []; for (let i = 0; i < refreshSession.newDenoms.length; i++) { @@ -2621,7 +2869,7 @@ export class Wallet { refreshSession.newDenomHashes[i], refreshSession.meltCoinPub, refreshSession.transferPubs[norevealIndex], - preCoins[i].coinEv, + planchets[i].coinEv, ); linkSigs.push(linkSig); } @@ -2682,7 +2930,7 @@ export class Wallet { continue; } const pc = - refreshSession.preCoinsForGammas[refreshSession.norevealIndex!][i]; + refreshSession.planchetsForGammas[refreshSession.norevealIndex!][i]; const denomSig = await this.cryptoApi.rsaUnblind( respJson.ev_sigs[i].ev_sig, pc.blindingKey, @@ -2699,6 +2947,8 @@ export class Wallet { exchangeBaseUrl: refreshSession.exchangeBaseUrl, reservePub: undefined, status: CoinStatus.Fresh, + coinIndex: -1, + withdrawSessionId: "", }; coins.push(coin); @@ -2761,7 +3011,7 @@ export class Wallet { const withdrawals = await oneShotIter( this.db, - Stores.withdrawals, + Stores.withdrawalSession, ).toArray(); for (const w of withdrawals) { history.push({ @@ -2822,7 +3072,7 @@ export class Wallet { history.push({ detail: { exchangeBaseUrl: r.exchangeBaseUrl, - requestedAmount: Amounts.toString(r.requestedAmount), + requestedAmount: Amounts.toString(r.initiallyRequestedAmount), reservePub: r.reservePub, reserveType, bankWithdrawStatusUrl: r.bankWithdrawStatusUrl, @@ -2835,7 +3085,7 @@ export class Wallet { history.push({ detail: { exchangeBaseUrl: r.exchangeBaseUrl, - requestedAmount: Amounts.toString(r.requestedAmount), + requestedAmount: Amounts.toString(r.initiallyRequestedAmount), reservePub: r.reservePub, reserveType, bankWithdrawStatusUrl: r.bankWithdrawStatusUrl, @@ -2956,11 +3206,23 @@ export class Wallet { case ReserveRecordStatus.WITHDRAWING: case ReserveRecordStatus.UNCONFIRMED: case ReserveRecordStatus.QUERYING_STATUS: + case ReserveRecordStatus.REGISTERING_BANK: pendingOperations.push({ type: "reserve", stage: reserve.reserveStatus, timestampCreated: reserve.created, reserveType, + reservePub: reserve.reservePub, + }); + break; + case ReserveRecordStatus.WAIT_CONFIRM_BANK: + pendingOperations.push({ + type: "reserve", + stage: reserve.reserveStatus, + timestampCreated: reserve.created, + reserveType, + reservePub: reserve.reservePub, + bankWithdrawConfirmUrl: reserve.bankWithdrawConfirmUrl, }); break; default: @@ -2992,16 +3254,55 @@ export class Wallet { oldCoinPub: r.meltCoinPub, refreshStatus, refreshOutputSize: r.newDenoms.length, + refreshSessionId: r.refreshSessionId, }); }); - await oneShotIter(this.db, Stores.precoins).forEach(pc => { + await oneShotIter(this.db, Stores.planchets).forEach(pc => { pendingOperations.push({ - type: "withdraw", - stage: "planchet", + type: "planchet", + coinPub: pc.coinPub, reservePub: pc.reservePub, }); }); + + await oneShotIter(this.db, Stores.coins).forEach(coin => { + if (coin.status == CoinStatus.Dirty) { + pendingOperations.push({ + type: "dirty-coin", + coinPub: coin.coinPub, + }); + } + }); + + await oneShotIter(this.db, Stores.withdrawalSession).forEach(ws => { + const numCoinsWithdrawn = ws.withdrawn.reduce( + (a, x) => a + (x ? 1 : 0), + 0, + ); + const numCoinsTotal = ws.withdrawn.length; + if (numCoinsWithdrawn < numCoinsTotal) { + pendingOperations.push({ + type: "withdraw", + numCoinsTotal, + numCoinsWithdrawn, + reservePub: ws.reservePub, + withdrawSessionId: ws.withdrawSessionId, + }); + } + }); + + await oneShotIter(this.db, Stores.proposals).forEach(proposal => { + if (proposal.proposalStatus == ProposalStatus.PROPOSED) { + pendingOperations.push({ + type: "proposal", + merchantBaseUrl: proposal.contractTerms.merchant_base_url, + proposalId: proposal.proposalId, + proposalTimestamp: proposal.timestamp, + }); + } + }); + return { pendingOperations, }; @@ -3016,9 +3317,7 @@ export class Wallet { return denoms; } - async getProposal( - proposalId: number, - ): Promise { + async getProposal(proposalId: string): Promise { const proposal = await oneShotGet(this.db, Stores.proposals, proposalId); return proposal; } @@ -3053,8 +3352,8 @@ export class Wallet { return await oneShotIter(this.db, Stores.coins).toArray(); } - async getPreCoins(exchangeBaseUrl: string): Promise { - return await oneShotIter(this.db, Stores.precoins).filter( + async getPlanchets(exchangeBaseUrl: string): Promise { + return await oneShotIter(this.db, Stores.planchets).filter( c => c.exchangeBaseUrl === exchangeBaseUrl, ); } @@ -3130,9 +3429,13 @@ export class Wallet { feeWithdraw: Amounts.parseOrThrow(denomIn.fee_withdraw), isOffered: true, masterSig: denomIn.master_sig, - stampExpireDeposit: extractTalerStampOrThrow(denomIn.stamp_expire_deposit), + stampExpireDeposit: extractTalerStampOrThrow( + denomIn.stamp_expire_deposit, + ), stampExpireLegal: extractTalerStampOrThrow(denomIn.stamp_expire_legal), - stampExpireWithdraw: extractTalerStampOrThrow(denomIn.stamp_expire_withdraw), + stampExpireWithdraw: extractTalerStampOrThrow( + denomIn.stamp_expire_withdraw, + ), stampStart: extractTalerStampOrThrow(denomIn.stamp_start), status: DenominationStatus.Unverified, value: Amounts.parseOrThrow(denomIn.value), @@ -3570,9 +3873,7 @@ export class Wallet { return feeAcc; } -async acceptTip( - talerTipUri: string, - ): Promise { + async acceptTip(talerTipUri: string): Promise { const { tipId, merchantOrigin } = await this.getTipStatus(talerTipUri); let tipRecord = await oneShotGet(this.db, Stores.tips, [ tipId, @@ -3647,22 +3948,24 @@ async acceptTip( } for (let i = 0; i < tipRecord.planchets.length; i++) { - const planchet = tipRecord.planchets[i]; - const preCoin = { - blindingKey: planchet.blindingKey, - coinEv: planchet.coinEv, - coinPriv: planchet.coinPriv, - coinPub: planchet.coinPub, - coinValue: planchet.coinValue, - denomPub: planchet.denomPub, - denomPubHash: planchet.denomPubHash, + const tipPlanchet = tipRecord.planchets[i]; + const planchet: PlanchetRecord = { + blindingKey: tipPlanchet.blindingKey, + coinEv: tipPlanchet.coinEv, + coinPriv: tipPlanchet.coinPriv, + coinPub: tipPlanchet.coinPub, + coinValue: tipPlanchet.coinValue, + denomPub: tipPlanchet.denomPub, + denomPubHash: tipPlanchet.denomPubHash, exchangeBaseUrl: tipRecord.exchangeUrl, isFromTip: true, reservePub: response.reserve_pub, withdrawSig: response.reserve_sigs[i].reserve_sig, + coinIndex: -1, + withdrawSessionId: "", }; - await oneShotPut(this.db, Stores.precoins, preCoin); - await this.processPreCoin(preCoin.coinPub); + await oneShotPut(this.db, Stores.planchets, planchet); + await this.processPlanchet(planchet.coinPub); } tipRecord.pickedUp = true; @@ -3794,6 +4097,19 @@ async acceptTip( }); } + public async handleNotifyReserve() { + const reserves = await oneShotIter(this.db, Stores.reserves).toArray(); + for (const r of reserves) { + if (r.reserveStatus === ReserveRecordStatus.WAIT_CONFIRM_BANK) { + try { + this.processReserveBankStatus(r.reservePub); + } catch (e) { + console.error(e); + } + } + } + } + /** * Remove unreferenced / expired data from the wallet's database * based on the current system time. @@ -3805,6 +4121,10 @@ async acceptTip( // strategy to test it. } + /** + * Get information about a withdrawal from + * a taler://withdraw URI. + */ async getWithdrawalInfo( talerWithdrawUri: string, ): Promise { @@ -3843,6 +4163,10 @@ async acceptTip( senderWire: withdrawInfo.senderWire, exchangeWire: exchangeWire, }); + // We do this here, as the reserve should be registered before we return, + // so that we can redirect the user to the bank's status page. + await this.processReserveBankStatus(reserve.reservePub); + console.log("acceptWithdrawal: returning"); return { reservePub: reserve.reservePub, confirmTransferUrl: withdrawInfo.confirmTransferUrl, @@ -3883,13 +4207,6 @@ async acceptTip( }; } - /** - * Reset the retry timeouts for ongoing operations. - */ - resetRetryTimeouts(): void { - // FIXME: implement - } - clearNotification(): void { this.badge.clearNotification(); } diff --git a/src/walletTypes.ts b/src/walletTypes.ts index b971e300d..45560694e 100644 --- a/src/walletTypes.ts +++ b/src/walletTypes.ts @@ -465,14 +465,14 @@ export type PreparePayResult = export interface PreparePayResultPaymentPossible { status: "payment-possible"; - proposalId: number; + proposalId: string; contractTerms: ContractTerms; totalFees: AmountJson; } export interface PreparePayResultInsufficientBalance { status: "insufficient-balance"; - proposalId: number; + proposalId: string; contractTerms: ContractTerms; } @@ -523,8 +523,10 @@ export interface WalletDiagnostics { export interface PendingWithdrawOperation { type: "withdraw"; - stage: string; reservePub: string; + withdrawSessionId: string; + numCoinsWithdrawn: number; + numCoinsTotal: number; } export interface PendingRefreshOperation { @@ -561,22 +563,47 @@ export interface PendingReserveOperation { stage: string; timestampCreated: Timestamp; reserveType: string; + reservePub: string; + bankWithdrawConfirmUrl?: string; } export interface PendingRefreshOperation { type: "refresh"; lastError?: OperationError; + refreshSessionId: string; oldCoinPub: string; refreshStatus: string; refreshOutputSize: number; } +export interface PendingPlanchetOperation { + type: "planchet"; + coinPub: string; + reservePub: string; + lastError?: OperationError; +} + +export interface PendingDirtyCoinOperation { + type: "dirty-coin"; + coinPub: string; +} + +export interface PendingProposalOperation { + type: "proposal"; + merchantBaseUrl: string; + proposalTimestamp: Timestamp; + proposalId: string; +} + export type PendingOperationInfo = | PendingWithdrawOperation | PendingReserveOperation | PendingBugOperation + | PendingPlanchetOperation + | PendingDirtyCoinOperation | PendingExchangeUpdateOperation - | PendingRefreshOperation; + | PendingRefreshOperation + | PendingProposalOperation; export interface PendingOperationsResponse { pendingOperations: PendingOperationInfo[]; @@ -614,3 +641,17 @@ export function getTimestampNow(): Timestamp { t_ms: new Date().getTime(), }; } + + +export interface PlanchetCreationResult { + coinPub: string; + coinPriv: string; + reservePub: string; + denomPubHash: string; + denomPub: string; + blindingKey: string; + withdrawSig: string; + coinEv: string; + exchangeBaseUrl: string; + coinValue: AmountJson; +} \ No newline at end of file diff --git a/src/webex/messages.ts b/src/webex/messages.ts index 034bf2849..e321e5ac1 100644 --- a/src/webex/messages.ts +++ b/src/webex/messages.ts @@ -66,7 +66,7 @@ export interface MessageMap { response: void; }; "confirm-pay": { - request: { proposalId: number; sessionId?: string }; + request: { proposalId: string; sessionId?: string }; response: walletTypes.ConfirmPayResult; }; "exchange-info": { @@ -113,9 +113,9 @@ export interface MessageMap { request: { reservePub: string }; response: dbTypes.ReserveRecord[]; }; - "get-precoins": { + "get-planchets": { request: { exchangeBaseUrl: string }; - response: dbTypes.PreCoinRecord[]; + response: dbTypes.PlanchetRecord[]; }; "get-denoms": { request: { exchangeBaseUrl: string }; diff --git a/src/webex/pages/payback.tsx b/src/webex/pages/payback.tsx index af14b95d4..806bef17c 100644 --- a/src/webex/pages/payback.tsx +++ b/src/webex/pages/payback.tsx @@ -57,7 +57,7 @@ function Payback() {
{reserves.map(r => (
-

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

+

Reserve for ${renderAmount(r.withdrawRemainingAmount)}

  • Exchange: ${r.exchangeBaseUrl}
diff --git a/src/webex/wxApi.ts b/src/webex/wxApi.ts index a50672131..a8b35ed34 100644 --- a/src/webex/wxApi.ts +++ b/src/webex/wxApi.ts @@ -28,7 +28,7 @@ import { CurrencyRecord, DenominationRecord, ExchangeRecord, - PreCoinRecord, + PlanchetRecord, ReserveRecord, } from "../dbTypes"; import { @@ -174,10 +174,10 @@ export function getCoins(exchangeBaseUrl: string): Promise { /** - * Get all precoins withdrawn from the given exchange. + * Get all planchets withdrawn from the given exchange. */ -export function getPreCoins(exchangeBaseUrl: string): Promise { - return callBackend("get-precoins", { exchangeBaseUrl }); +export function getPlanchets(exchangeBaseUrl: string): Promise { + return callBackend("get-planchets", { exchangeBaseUrl }); } @@ -207,7 +207,7 @@ export function payback(coinPub: string): Promise { /** * Pay for a proposal. */ -export function confirmPay(proposalId: number, sessionId: string | undefined): Promise { +export function confirmPay(proposalId: string, sessionId: string | undefined): Promise { return callBackend("confirm-pay", { proposalId, sessionId }); } diff --git a/src/webex/wxBackend.ts b/src/webex/wxBackend.ts index 57c10d94a..78c86a976 100644 --- a/src/webex/wxBackend.ts +++ b/src/webex/wxBackend.ts @@ -117,8 +117,8 @@ async function handleMessage( return needsWallet().confirmReserve(req); } case "confirm-pay": { - if (typeof detail.proposalId !== "number") { - throw Error("proposalId must be number"); + if (typeof detail.proposalId !== "string") { + throw Error("proposalId must be string"); } return needsWallet().confirmPay(detail.proposalId, detail.sessionId); } @@ -178,11 +178,11 @@ async function handleMessage( } return needsWallet().getCoinsForExchange(detail.exchangeBaseUrl); } - case "get-precoins": { + case "get-planchets": { if (typeof detail.exchangeBaseUrl !== "string") { return Promise.reject(Error("exchangBaseUrl missing")); } - return needsWallet().getPreCoins(detail.exchangeBaseUrl); + return needsWallet().getPlanchets(detail.exchangeBaseUrl); } case "get-denoms": { if (typeof detail.exchangeBaseUrl !== "string") { @@ -658,8 +658,8 @@ export async function wxMain() { if (!wallet) { console.warn("wallet not available while handling header"); } - if (details.statusCode === 402) { - console.log(`got 402 from ${details.url}`); + if (details.statusCode === 402 || details.statusCode === 202) { + console.log(`got 402/202 from ${details.url}`); for (let header of details.responseHeaders || []) { if (header.name.toLowerCase() === "taler") { const talerUri = header.value || ""; @@ -705,6 +705,15 @@ export async function wxMain() { talerRefundUri: talerUri, }, ); + } else if (talerUri.startsWith("taler://notify-reserve/")) { + Promise.resolve().then(() => { + const w = currentWallet; + if (!w) { + return; + } + w.handleNotifyReserve(); + }); + } else { console.warn("Unknown action in taler:// URI, ignoring."); } diff --git a/yarn.lock b/yarn.lock index 56697f55d..22bcd5fd3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3417,10 +3417,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.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== +idb-bridge@^0.0.15: + version "0.0.15" + resolved "https://registry.yarnpkg.com/idb-bridge/-/idb-bridge-0.0.15.tgz#3fddc91b9aab775fae273d02b272205c6090d270" + integrity sha512-xuZM/i4vCm/NkqyrKNJDEuBaZK7M2kyj+1F4hDGqtEJZSmQMSV3v9A6Ie3fR12VXDKIbMr7uV22eWjIKwSosOA== ieee754@^1.1.4: version "1.1.13"