diff options
author | Florian Dold <florian.dold@gmail.com> | 2019-12-02 00:42:40 +0100 |
---|---|---|
committer | Florian Dold <florian.dold@gmail.com> | 2019-12-02 00:42:40 +0100 |
commit | e1369ff7e8fc02116b9c4261036f0e42e3423cf4 (patch) | |
tree | c621067ebda8977a888bfed34b7bbecf64b3b0f0 /src | |
parent | aaf7e1338d6cdb1b4e01ad318938b3eaea2f922b (diff) |
the giant refactoring: split wallet into multiple parts
Diffstat (limited to 'src')
57 files changed, 4836 insertions, 4111 deletions
diff --git a/src/android/index.ts b/src/android/index.ts index ab0d3f7b5..6a29f7946 100644 --- a/src/android/index.ts +++ b/src/android/index.ts @@ -24,10 +24,10 @@ import { DefaultNodeWalletArgs, NodeHttpLib, } from "../headless/helpers"; -import { openPromise, OpenedPromise } from "../promiseUtils"; +import { openPromise, OpenedPromise } from "../util/promiseUtils"; import fs = require("fs"); import axios from "axios"; -import { HttpRequestLibrary, HttpResponse } from "../http"; +import { HttpRequestLibrary, HttpResponse } from "../util/http"; import querystring = require("querystring"); // @ts-ignore: special built-in module @@ -66,7 +66,7 @@ export class AndroidHttpLib implements HttpRequestLibrary { } } - postJson(url: string, body: any): Promise<import("../http").HttpResponse> { + postJson(url: string, body: any): Promise<import("../util/http").HttpResponse> { if (this.useNfcTunnel) { const myId = this.requestId++; const p = openPromise<HttpResponse>(); diff --git a/src/crypto/cryptoApi.ts b/src/crypto/cryptoApi.ts index b5eae9beb..5ef787711 100644 --- a/src/crypto/cryptoApi.ts +++ b/src/crypto/cryptoApi.ts @@ -22,12 +22,11 @@ /** * Imports. */ -import { AmountJson } from "../amounts"; +import { AmountJson } from "../util/amounts"; import { CoinRecord, DenominationRecord, - PlanchetRecord, RefreshSessionRecord, ReserveRecord, TipPlanchet, @@ -38,9 +37,9 @@ import { CryptoWorker } from "./cryptoWorker"; import { ContractTerms, PaybackRequest } from "../talerTypes"; -import { BenchmarkResult, CoinWithDenom, PayCoinInfo, PlanchetCreationResult } from "../walletTypes"; +import { BenchmarkResult, CoinWithDenom, PayCoinInfo, PlanchetCreationResult, PlanchetCreationRequest } from "../walletTypes"; -import * as timer from "../timer"; +import * as timer from "../util/timer"; /** * State of a crypto worker. @@ -336,10 +335,9 @@ export class CryptoApi { } createPlanchet( - denom: DenominationRecord, - reserve: ReserveRecord, + req: PlanchetCreationRequest ): Promise<PlanchetCreationResult> { - return this.doRpc<PlanchetCreationResult>("createPlanchet", 1, denom, reserve); + return this.doRpc<PlanchetCreationResult>("createPlanchet", 1, req); } createTipPlanchet(denom: DenominationRecord): Promise<TipPlanchet> { diff --git a/src/crypto/cryptoImplementation.ts b/src/crypto/cryptoImplementation.ts index 7cddf9031..faebbaa4a 100644 --- a/src/crypto/cryptoImplementation.ts +++ b/src/crypto/cryptoImplementation.ts @@ -42,11 +42,12 @@ import { PayCoinInfo, Timestamp, PlanchetCreationResult, + PlanchetCreationRequest, } from "../walletTypes"; -import { canonicalJson, getTalerStampSec } from "../helpers"; -import { AmountJson } from "../amounts"; -import * as Amounts from "../amounts"; -import * as timer from "../timer"; +import { canonicalJson, getTalerStampSec } from "../util/helpers"; +import { AmountJson } from "../util/amounts"; +import * as Amounts from "../util/amounts"; +import * as timer from "../util/timer"; import { getRandomBytes, encodeCrock, @@ -155,24 +156,23 @@ export class CryptoImplementation { * reserve. */ createPlanchet( - denom: DenominationRecord, - reserve: ReserveRecord, + req: PlanchetCreationRequest, ): PlanchetCreationResult { - const reservePub = decodeCrock(reserve.reservePub); - const reservePriv = decodeCrock(reserve.reservePriv); - const denomPub = decodeCrock(denom.denomPub); + const reservePub = decodeCrock(req.reservePub); + const reservePriv = decodeCrock(req.reservePriv); + const denomPub = decodeCrock(req.denomPub); const coinKeyPair = createEddsaKeyPair(); const blindingFactor = createBlindingKeySecret(); const coinPubHash = hash(coinKeyPair.eddsaPub); const ev = rsaBlind(coinPubHash, blindingFactor, denomPub); - const amountWithFee = Amounts.add(denom.value, denom.feeWithdraw).amount; + const amountWithFee = Amounts.add(req.value, req.feeWithdraw).amount; const denomPubHash = hash(denomPub); const evHash = hash(ev); const withdrawRequest = buildSigPS(SignaturePurpose.RESERVE_WITHDRAW) .put(reservePub) .put(amountToBuffer(amountWithFee)) - .put(amountToBuffer(denom.feeWithdraw)) + .put(amountToBuffer(req.feeWithdraw)) .put(denomPubHash) .put(evHash) .build(); @@ -184,10 +184,9 @@ export class CryptoImplementation { coinEv: encodeCrock(ev), coinPriv: encodeCrock(coinKeyPair.eddsaPriv), coinPub: encodeCrock(coinKeyPair.eddsaPub), - coinValue: denom.value, + coinValue: req.value, denomPub: encodeCrock(denomPub), denomPubHash: encodeCrock(denomPubHash), - exchangeBaseUrl: reserve.exchangeBaseUrl, reservePub: encodeCrock(reservePub), withdrawSig: encodeCrock(sig), }; @@ -1,5 +1,5 @@ import { Stores, WALLET_DB_VERSION } from "./dbTypes"; -import { Store, Index } from "./query"; +import { Store, Index } from "./util/query"; const DB_NAME = "taler"; @@ -21,9 +21,7 @@ export function openTalerDb( req.onsuccess = e => { req.result.onversionchange = (evt: IDBVersionChangeEvent) => { console.log( - `handling live db version change from ${evt.oldVersion} to ${ - evt.newVersion - }`, + `handling live db version change from ${evt.oldVersion} to ${evt.newVersion}`, ); req.result.close(); onVersionChange(); @@ -33,9 +31,7 @@ export function openTalerDb( req.onupgradeneeded = e => { const db = req.result; console.log( - `DB: upgrade needed: oldVersion=${e.oldVersion}, newVersion=${ - e.newVersion - }`, + `DB: upgrade needed: oldVersion=${e.oldVersion}, newVersion=${e.newVersion}`, ); switch (e.oldVersion) { case 0: // DB does not exist yet diff --git a/src/dbTypes.ts b/src/dbTypes.ts index 8dba28edb..731f0358b 100644 --- a/src/dbTypes.ts +++ b/src/dbTypes.ts @@ -23,8 +23,8 @@ /** * Imports. */ -import { AmountJson } from "./amounts"; -import { Checkable } from "./checkable"; +import { AmountJson } from "./util/amounts"; +import { Checkable } from "./util/checkable"; import { Auditor, CoinPaySig, @@ -35,7 +35,7 @@ import { TipResponse, } from "./talerTypes"; -import { Index, Store } from "./query"; +import { Index, Store } from "./util/query"; import { Timestamp, OperationError } from "./walletTypes"; /** @@ -444,30 +444,22 @@ export interface ExchangeRecord { * A coin that isn't yet signed by an exchange. */ export interface PlanchetRecord { - withdrawSessionId: string; - /** - * Index of the coin in the withdrawal session. - */ - coinIndex: number; - /** * Public key of the coin. */ coinPub: string; coinPriv: string; + /** + * Public key of the reserve, this might be a reserve not + * known to the wallet if the planchet is from a tip. + */ reservePub: string; denomPubHash: string; denomPub: string; blindingKey: string; withdrawSig: string; coinEv: string; - exchangeBaseUrl: string; coinValue: AmountJson; - /** - * Set to true if this pre-coin came from a tip. - * Until the tip is marked as "accepted", the resulting - * coin will not be used for payments. - */ isFromTip: boolean; } @@ -511,6 +503,12 @@ export enum CoinStatus { Dormant = "dormant", } +export enum CoinSource { + Withdraw = "withdraw", + Refresh = "refresh", + Tip = "tip", +} + /** * CoinRecord as stored in the "coins" data store * of the wallet database. @@ -690,11 +688,9 @@ export interface TipRecord { exchangeUrl: string; /** - * Domain of the merchant, necessary to uniquely identify the tip since - * merchants can freely choose the ID and a malicious merchant might cause a - * collision. + * Base URL of the merchant that is giving us the tip. */ - merchantDomain: string; + merchantBaseUrl: string; /** * Planchets, the members included in TipPlanchetDetail will be sent to the @@ -703,31 +699,27 @@ export interface TipRecord { planchets?: TipPlanchet[]; /** - * Coin public keys from the planchets. - * This field is redundant and used for indexing the record via - * a multi-entry index to look up tip records by coin public key. - */ - coinPubs: string[]; - - /** * Response if the merchant responded, * undefined otherwise. */ response?: TipResponse[]; /** - * Identifier for the tip, chosen by the merchant. + * Tip ID chosen by the wallet. */ tipId: string; /** + * The merchant's identifier for this tip. + */ + merchantTipId: string; + + /** * URL to go to once the tip has been accepted. */ nextUrl?: string; timestamp: Timestamp; - - pickupUrl: string; } /** @@ -983,13 +975,24 @@ export interface CoinsReturnRecord { wire: any; } +export interface WithdrawalSourceTip { + type: "tip"; + tipId: string; +} + +export interface WithdrawalSourceReserve { + type: "reserve"; + reservePub: string; +} + +export type WithdrawalSource = WithdrawalSourceTip | WithdrawalSourceReserve + export interface WithdrawalSessionRecord { withdrawSessionId: string; - /** - * Reserve that we're withdrawing from. - */ - reservePub: string; + source: WithdrawalSource; + + exchangeBaseUrl: string; /** * When was the withdrawal operation started started? @@ -1010,15 +1013,12 @@ export interface WithdrawalSessionRecord { denoms: string[]; + planchets: (undefined | PlanchetRecord)[]; + /** * 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 { @@ -1071,11 +1071,7 @@ export namespace Stores { constructor() { super("proposals", { keyPath: "proposalId" }); } - urlIndex = new Index<string, ProposalRecord>( - this, - "urlIndex", - "url", - ); + urlIndex = new Index<string, ProposalRecord>(this, "urlIndex", "url"); } class PurchasesStore extends Store<PurchaseRecord> { @@ -1140,16 +1136,8 @@ export namespace Stores { class TipsStore extends Store<TipRecord> { constructor() { - super("tips", { - keyPath: (["tipId", "merchantDomain"] as any) as IDBKeyPath, - }); + super("tips", { keyPath: "tipId" }); } - coinPubIndex = new Index<string, TipRecord>( - this, - "coinPubIndex", - "coinPubs", - { multiEntry: true }, - ); } class SenderWiresStore extends Store<SenderWireRecord> { @@ -1162,11 +1150,6 @@ export namespace Stores { constructor() { super("withdrawals", { keyPath: "withdrawSessionId" }); } - byReservePub = new Index<string, WithdrawalSessionRecord>( - this, - "withdrawalsReservePubIndex", - "reservePub", - ); } class BankWithdrawUrisStore extends Store<BankWithdrawUriRecord> { @@ -1175,24 +1158,6 @@ export namespace Stores { } } - class PlanchetsStore extends Store<PlanchetRecord> { - constructor() { - super("planchets", { - keyPath: "coinPub", - }); - } - byReservePub = new Index<string, PlanchetRecord>( - this, - "planchetsReservePubIndex", - "reservePub", - ); - byWithdrawalWithIdx = new Index<any, PlanchetRecord>( - this, - "planchetsByWithdrawalWithIdxIndex", - ["withdrawSessionId", "coinIndex"], - ); - } - export const coins = new CoinsStore(); export const coinsReturns = new Store<CoinsReturnRecord>("coinsReturns", { keyPath: "contractTermsHash", @@ -1201,7 +1166,6 @@ export namespace Stores { export const currencies = new CurrenciesStore(); export const denominations = new DenominationsStore(); export const exchanges = new ExchangesStore(); - export const planchets = new PlanchetsStore(); export const proposals = new ProposalsStore(); export const refresh = new Store<RefreshSessionRecord>("refresh", { keyPath: "refreshSessionId", diff --git a/src/headless/bank.ts b/src/headless/bank.ts index 36f61a71a..99d7e050b 100644 --- a/src/headless/bank.ts +++ b/src/headless/bank.ts @@ -25,7 +25,6 @@ */ import Axios from "axios"; import querystring = require("querystring"); -import URI = require("urijs"); export interface BankUser { username: string; @@ -50,9 +49,7 @@ export class Bank { amount, }; - const reqUrl = new URI("api/withdraw-headless-uri") - .absoluteTo(this.bankBaseUrl) - .href(); + const reqUrl = new URL("api/withdraw-headless-uri", this.bankBaseUrl).href; const resp = await Axios({ method: "post", @@ -82,9 +79,7 @@ export class Bank { reservePub: string, exchangePaytoUri: string, ) { - const reqUrl = new URI("api/withdraw-headless") - .absoluteTo(this.bankBaseUrl) - .href(); + const reqUrl = new URL("api/withdraw-headless", this.bankBaseUrl).href; const body = { auth: { type: "basic" }, @@ -111,7 +106,7 @@ export class Bank { } async registerRandomUser(): Promise<BankUser> { - const reqUrl = new URI("api/register").absoluteTo(this.bankBaseUrl).href(); + const reqUrl = new URL("api/register", this.bankBaseUrl).href; const randId = makeId(8); const bankUser: BankUser = { username: `testuser-${randId}`, diff --git a/src/headless/helpers.ts b/src/headless/helpers.ts index 9faf24daf..e5338369e 100644 --- a/src/headless/helpers.ts +++ b/src/headless/helpers.ts @@ -28,13 +28,13 @@ import { SynchronousCryptoWorkerFactory } from "../crypto/synchronousWorker"; import { openTalerDb } from "../db"; import Axios from "axios"; import querystring = require("querystring"); -import { HttpRequestLibrary } from "../http"; -import * as amounts from "../amounts"; +import { HttpRequestLibrary } from "../util/http"; +import * as amounts from "../util/amounts"; import { Bank } from "./bank"; import fs = require("fs"); import { NodeCryptoWorkerFactory } from "../crypto/nodeProcessWorker"; -import { Logger } from "../logging"; +import { Logger } from "../util/logging"; const logger = new Logger("helpers.ts"); @@ -51,7 +51,7 @@ class ConsoleBadge implements Badge { } export class NodeHttpLib implements HttpRequestLibrary { - async get(url: string): Promise<import("../http").HttpResponse> { + async get(url: string): Promise<import("../util/http").HttpResponse> { try { const resp = await Axios({ method: "get", @@ -70,7 +70,7 @@ export class NodeHttpLib implements HttpRequestLibrary { async postJson( url: string, body: any, - ): Promise<import("../http").HttpResponse> { + ): Promise<import("../util/http").HttpResponse> { try { const resp = await Axios({ method: "post", diff --git a/src/headless/merchant.ts b/src/headless/merchant.ts index 423e3d09e..1b9630732 100644 --- a/src/headless/merchant.ts +++ b/src/headless/merchant.ts @@ -24,7 +24,6 @@ */ import axios from "axios"; import { CheckPaymentResponse } from "../talerTypes"; -import URI = require("urijs"); /** * Connection to the *internal* merchant backend. @@ -35,7 +34,7 @@ export class MerchantBackendConnection { reason: string, refundAmount: string, ): Promise<void> { - const reqUrl = new URI("refund").absoluteTo(this.merchantBaseUrl).href(); + const reqUrl = new URL("refund", this.merchantBaseUrl); const refundReq = { order_id: orderId, reason, @@ -43,7 +42,7 @@ export class MerchantBackendConnection { }; const resp = await axios({ method: "post", - url: reqUrl, + url: reqUrl.href, data: refundReq, responseType: "json", headers: { @@ -64,7 +63,7 @@ export class MerchantBackendConnection { constructor(public merchantBaseUrl: string, public apiKey: string) {} async authorizeTip(amount: string, justification: string) { - const reqUrl = new URI("tip-authorize").absoluteTo(this.merchantBaseUrl).href(); + const reqUrl = new URL("tip-authorize", this.merchantBaseUrl).href; const tipReq = { amount, justification, @@ -90,7 +89,7 @@ export class MerchantBackendConnection { summary: string, fulfillmentUrl: string, ): Promise<{ orderId: string }> { - const reqUrl = new URI("order").absoluteTo(this.merchantBaseUrl).href(); + const reqUrl = new URL("order", this.merchantBaseUrl).href; const orderReq = { order: { amount, @@ -118,9 +117,7 @@ export class MerchantBackendConnection { } async checkPayment(orderId: string): Promise<CheckPaymentResponse> { - const reqUrl = new URI("check-payment") - .absoluteTo(this.merchantBaseUrl) - .href(); + const reqUrl = new URL("check-payment", this.merchantBaseUrl).href; const resp = await axios({ method: "get", url: reqUrl, diff --git a/src/headless/taler-wallet-cli.ts b/src/headless/taler-wallet-cli.ts index cb2ff055c..9598b9d98 100644 --- a/src/headless/taler-wallet-cli.ts +++ b/src/headless/taler-wallet-cli.ts @@ -23,8 +23,8 @@ 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"; +import { Logger } from "../util/logging"; +import * as Amounts from "../util/amounts"; import { decodeCrock } from "../crypto/talerCrypto"; import { Bank } from "./bank"; @@ -93,7 +93,6 @@ async function doPay( function applyVerbose(verbose: boolean) { if (verbose) { console.log("enabled verbose logging"); - Wallet.enableTracing = true; BridgeIDBFactory.enableTracing = true; } } @@ -217,7 +216,7 @@ walletCli } else if (uri.startsWith("taler://tip/")) { const res = await wallet.getTipStatus(uri); console.log("tip status", res); - await wallet.acceptTip(uri); + await wallet.acceptTip(res.tipId); } else if (uri.startsWith("taler://refund/")) { await wallet.applyRefund(uri); } else if (uri.startsWith("taler://withdraw/")) { diff --git a/src/talerTypes.ts b/src/talerTypes.ts index 1e658d5be..a65813678 100644 --- a/src/talerTypes.ts +++ b/src/talerTypes.ts @@ -26,11 +26,11 @@ /** * Imports. */ -import { Checkable } from "./checkable"; +import { Checkable } from "./util/checkable"; -import * as Amounts from "./amounts"; +import * as Amounts from "./util/amounts"; -import { timestampCheck } from "./helpers"; +import { timestampCheck } from "./util/helpers"; /** * Denomination as found in the /keys response from the exchange. diff --git a/src/types-test.ts b/src/types-test.ts index 56a826441..38cb9260a 100644 --- a/src/types-test.ts +++ b/src/types-test.ts @@ -15,12 +15,16 @@ */ import test from "ava"; -import * as Amounts from "./amounts"; +import * as Amounts from "./util/amounts"; import { ContractTerms } from "./talerTypes"; -const amt = (value: number, fraction: number, currency: string): Amounts.AmountJson => ({value, fraction, currency}); +const amt = ( + value: number, + fraction: number, + currency: string, +): Amounts.AmountJson => ({ value, fraction, currency }); -test("amount addition (simple)", (t) => { +test("amount addition (simple)", t => { const a1 = amt(1, 0, "EUR"); const a2 = amt(1, 0, "EUR"); const a3 = amt(2, 0, "EUR"); @@ -28,14 +32,14 @@ test("amount addition (simple)", (t) => { t.pass(); }); -test("amount addition (saturation)", (t) => { +test("amount addition (saturation)", t => { const a1 = amt(1, 0, "EUR"); const res = Amounts.add(amt(Amounts.maxAmountValue, 0, "EUR"), a1); t.true(res.saturated); t.pass(); }); -test("amount subtraction (simple)", (t) => { +test("amount subtraction (simple)", t => { const a1 = amt(2, 5, "EUR"); const a2 = amt(1, 0, "EUR"); const a3 = amt(1, 5, "EUR"); @@ -43,7 +47,7 @@ test("amount subtraction (simple)", (t) => { t.pass(); }); -test("amount subtraction (saturation)", (t) => { +test("amount subtraction (saturation)", t => { const a1 = amt(0, 0, "EUR"); const a2 = amt(1, 0, "EUR"); let res = Amounts.sub(a1, a2); @@ -53,8 +57,7 @@ test("amount subtraction (saturation)", (t) => { t.pass(); }); - -test("amount comparison", (t) => { +test("amount comparison", t => { t.is(Amounts.cmp(amt(1, 0, "EUR"), amt(1, 0, "EUR")), 0); t.is(Amounts.cmp(amt(1, 1, "EUR"), amt(1, 0, "EUR")), 1); t.is(Amounts.cmp(amt(1, 1, "EUR"), amt(1, 2, "EUR")), -1); @@ -65,18 +68,36 @@ test("amount comparison", (t) => { t.pass(); }); - -test("amount parsing", (t) => { - t.is(Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:0"), - amt(0, 0, "TESTKUDOS")), 0); - t.is(Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:10"), - amt(10, 0, "TESTKUDOS")), 0); - t.is(Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:0.1"), - amt(0, 10000000, "TESTKUDOS")), 0); - t.is(Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:0.00000001"), - amt(0, 1, "TESTKUDOS")), 0); - t.is(Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:4503599627370496.99999999"), - amt(4503599627370496, 99999999, "TESTKUDOS")), 0); +test("amount parsing", t => { + t.is( + Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:0"), amt(0, 0, "TESTKUDOS")), + 0, + ); + t.is( + Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:10"), amt(10, 0, "TESTKUDOS")), + 0, + ); + t.is( + Amounts.cmp( + Amounts.parseOrThrow("TESTKUDOS:0.1"), + amt(0, 10000000, "TESTKUDOS"), + ), + 0, + ); + t.is( + Amounts.cmp( + Amounts.parseOrThrow("TESTKUDOS:0.00000001"), + amt(0, 1, "TESTKUDOS"), + ), + 0, + ); + t.is( + Amounts.cmp( + Amounts.parseOrThrow("TESTKUDOS:4503599627370496.99999999"), + amt(4503599627370496, 99999999, "TESTKUDOS"), + ), + 0, + ); t.throws(() => Amounts.parseOrThrow("foo:")); t.throws(() => Amounts.parseOrThrow("1.0")); t.throws(() => Amounts.parseOrThrow("42")); @@ -85,14 +106,18 @@ test("amount parsing", (t) => { t.throws(() => Amounts.parseOrThrow("EUR:.42")); t.throws(() => Amounts.parseOrThrow("EUR:42.")); t.throws(() => Amounts.parseOrThrow("TESTKUDOS:4503599627370497.99999999")); - t.is(Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:0.99999999"), - amt(0, 99999999, "TESTKUDOS")), 0); + t.is( + Amounts.cmp( + Amounts.parseOrThrow("TESTKUDOS:0.99999999"), + amt(0, 99999999, "TESTKUDOS"), + ), + 0, + ); t.throws(() => Amounts.parseOrThrow("TESTKUDOS:0.999999991")); t.pass(); }); - -test("amount stringification", (t) => { +test("amount stringification", t => { t.is(Amounts.toString(amt(0, 0, "TESTKUDOS")), "TESTKUDOS:0"); t.is(Amounts.toString(amt(4, 94000000, "TESTKUDOS")), "TESTKUDOS:4.94"); t.is(Amounts.toString(amt(0, 10000000, "TESTKUDOS")), "TESTKUDOS:0.1"); @@ -103,13 +128,12 @@ test("amount stringification", (t) => { t.pass(); }); - -test("contract terms validation", (t) => { +test("contract terms validation", t => { const c = { H_wire: "123", amount: "EUR:1.5", auditors: [], - exchanges: [{master_pub: "foo", url: "foo"}], + exchanges: [{ master_pub: "foo", url: "foo" }], fulfillment_url: "foo", max_fee: "EUR:1.5", merchant_pub: "12345", diff --git a/src/amounts.ts b/src/util/amounts.ts index b90d54a31..b90d54a31 100644 --- a/src/amounts.ts +++ b/src/util/amounts.ts diff --git a/src/util/assertUnreachable.ts b/src/util/assertUnreachable.ts new file mode 100644 index 000000000..90f2476b4 --- /dev/null +++ b/src/util/assertUnreachable.ts @@ -0,0 +1,19 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +export function assertUnreachable(x: never): never { + throw new Error("Didn't expect to get here"); +}
\ No newline at end of file diff --git a/src/util/asyncMemo.ts b/src/util/asyncMemo.ts new file mode 100644 index 000000000..8b7b1c9bb --- /dev/null +++ b/src/util/asyncMemo.ts @@ -0,0 +1,52 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +export interface MemoEntry<T> { + p: Promise<T>; + t: number; + n: number; +} + +export class AsyncOpMemo<T> { + n = 0; + memo: { [k: string]: MemoEntry<T> } = {}; + put(key: string, p: Promise<T>): Promise<T> { + 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<T> | 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; + } +}
\ No newline at end of file diff --git a/src/checkable.ts b/src/util/checkable.ts index 3c9fe5bc1..3c9fe5bc1 100644 --- a/src/checkable.ts +++ b/src/util/checkable.ts diff --git a/src/helpers-test.ts b/src/util/helpers-test.ts index 74817120a..74817120a 100644 --- a/src/helpers-test.ts +++ b/src/util/helpers-test.ts diff --git a/src/helpers.ts b/src/util/helpers.ts index 1983cee9b..eb8a1c7b2 100644 --- a/src/helpers.ts +++ b/src/util/helpers.ts @@ -24,8 +24,7 @@ import { AmountJson } from "./amounts"; import * as Amounts from "./amounts"; -import URI = require("urijs"); -import { Timestamp } from "./walletTypes"; +import { Timestamp } from "../walletTypes"; /** * Show an amount in a form suitable for the user. @@ -47,11 +46,13 @@ export function canonicalizeBaseUrl(url: string) { if (!url.startsWith("http") && !url.startsWith("https")) { url = "https://" + url; } - const x = new URI(url); - x.path(x.path() + "/").normalizePath(); - x.fragment(""); - x.query(); - return x.href(); + const x = new URL(url); + if (!x.pathname.endsWith("/")) { + x.pathname = x.pathname + "/"; + } + x.search = ""; + x.hash = ""; + return x.href; } diff --git a/src/http.ts b/src/util/http.ts index a2bfab279..a2bfab279 100644 --- a/src/http.ts +++ b/src/util/http.ts diff --git a/src/libtoolVersion-test.ts b/src/util/libtoolVersion-test.ts index 0a610e455..0a610e455 100644 --- a/src/libtoolVersion-test.ts +++ b/src/util/libtoolVersion-test.ts diff --git a/src/libtoolVersion.ts b/src/util/libtoolVersion.ts index cc2435b94..cc2435b94 100644 --- a/src/libtoolVersion.ts +++ b/src/util/libtoolVersion.ts diff --git a/src/logging.ts b/src/util/logging.ts index a21943e6e..309d1593b 100644 --- a/src/logging.ts +++ b/src/util/logging.ts @@ -19,7 +19,7 @@ export class Logger { info(message: string, ...args: any[]) { console.log(`${new Date().toISOString()} ${this.tag} INFO ` + message, ...args); } - trace(message: string, ...args: any[]) { + trace(message: any, ...args: any[]) { console.log(`${new Date().toISOString()} ${this.tag} TRACE ` + message, ...args) } }
\ No newline at end of file diff --git a/src/util/payto-test.ts b/src/util/payto-test.ts new file mode 100644 index 000000000..82daff164 --- /dev/null +++ b/src/util/payto-test.ts @@ -0,0 +1,31 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import test from "ava"; + +import { parsePaytoUri } from "./payto"; + +test("basic payto parsing", (t) => { + const r1 = parsePaytoUri("https://example.com/"); + t.is(r1, undefined); + + const r2 = parsePaytoUri("payto:blabla"); + t.is(r2, undefined); + + const r3 = parsePaytoUri("payto://x-taler-bank/123"); + t.is(r3?.targetType, "x-taler-bank"); + t.is(r3?.targetPath, "123"); +});
\ No newline at end of file diff --git a/src/util/payto.ts b/src/util/payto.ts new file mode 100644 index 000000000..0926fdeed --- /dev/null +++ b/src/util/payto.ts @@ -0,0 +1,54 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +interface PaytoUri { + targetType: string; + targetPath: string; + params: { [name: string]: string }; +} + + +export function parsePaytoUri(s: string): PaytoUri | undefined { + const pfx = "payto://" + if (!s.startsWith(pfx)) { + return undefined; + } + + const [acct, search] = s.slice(pfx.length).split("?"); + + const firstSlashPos = acct.indexOf("/"); + + if (firstSlashPos === -1) { + return undefined; + } + + const targetType = acct.slice(0, firstSlashPos); + const targetPath = acct.slice(firstSlashPos + 1); + + const params: { [k: string]: string } = {}; + + const searchParams = new URLSearchParams(search || ""); + + searchParams.forEach((v, k) => { + params[v] = k; + }); + + return { + targetPath, + targetType, + params, + } +}
\ No newline at end of file diff --git a/src/promiseUtils.ts b/src/util/promiseUtils.ts index eb649471b..eb649471b 100644 --- a/src/promiseUtils.ts +++ b/src/util/promiseUtils.ts diff --git a/src/query.ts b/src/util/query.ts index 5726bcaa6..5726bcaa6 100644 --- a/src/query.ts +++ b/src/util/query.ts diff --git a/src/taleruri-test.ts b/src/util/taleruri-test.ts index 360f565f7..02eecf209 100644 --- a/src/taleruri-test.ts +++ b/src/util/taleruri-test.ts @@ -1,5 +1,5 @@ /* - This file is part of TALER + This file is part of GNU Taler (C) 2019 GNUnet e.V. GNU Taler is free software; you can redistribute it and/or modify it under the @@ -15,9 +15,14 @@ */ import test from "ava"; -import { parsePayUri, parseWithdrawUri, parseRefundUri, parseTipUri } from "./taleruri"; - -test("taler pay url parsing: http(s)", (t) => { +import { + parsePayUri, + parseWithdrawUri, + parseRefundUri, + parseTipUri, +} from "./taleruri"; + +test("taler pay url parsing: http(s)", t => { const url1 = "https://example.com/bar?spam=eggs"; const r1 = parsePayUri(url1); if (!r1) { @@ -34,8 +39,7 @@ test("taler pay url parsing: http(s)", (t) => { } }); - -test("taler pay url parsing: wrong scheme", (t) => { +test("taler pay url parsing: wrong scheme", t => { const url1 = "talerfoo://"; const r1 = parsePayUri(url1); t.is(r1, undefined); @@ -45,8 +49,7 @@ test("taler pay url parsing: wrong scheme", (t) => { t.is(r2, undefined); }); - -test("taler pay url parsing: defaults", (t) => { +test("taler pay url parsing: defaults", t => { const url1 = "taler://pay/example.com/-/-/myorder"; const r1 = parsePayUri(url1); if (!r1) { @@ -66,8 +69,7 @@ test("taler pay url parsing: defaults", (t) => { t.is(r2.sessionId, "mysession"); }); - -test("taler pay url parsing: trailing parts", (t) => { +test("taler pay url parsing: trailing parts", t => { const url1 = "taler://pay/example.com/-/-/myorder/mysession/spam/eggs"; const r1 = parsePayUri(url1); if (!r1) { @@ -78,49 +80,59 @@ test("taler pay url parsing: trailing parts", (t) => { t.is(r1.sessionId, "mysession"); }); - -test("taler pay url parsing: instance", (t) => { +test("taler pay url parsing: instance", t => { const url1 = "taler://pay/example.com/-/myinst/myorder"; const r1 = parsePayUri(url1); if (!r1) { t.fail(); return; } - t.is(r1.downloadUrl, "https://example.com/public/instances/myinst/proposal?order_id=myorder"); + t.is( + r1.downloadUrl, + "https://example.com/public/instances/myinst/proposal?order_id=myorder", + ); }); - -test("taler pay url parsing: path prefix and instance", (t) => { +test("taler pay url parsing: path prefix and instance", t => { const url1 = "taler://pay/example.com/mypfx/myinst/myorder"; const r1 = parsePayUri(url1); if (!r1) { t.fail(); return; } - t.is(r1.downloadUrl, "https://example.com/mypfx/instances/myinst/proposal?order_id=myorder"); + t.is( + r1.downloadUrl, + "https://example.com/mypfx/instances/myinst/proposal?order_id=myorder", + ); }); -test("taler pay url parsing: complex path prefix", (t) => { +test("taler pay url parsing: complex path prefix", t => { const url1 = "taler://pay/example.com/mypfx%2Fpublic/-/myorder"; const r1 = parsePayUri(url1); if (!r1) { t.fail(); return; } - t.is(r1.downloadUrl, "https://example.com/mypfx/public/proposal?order_id=myorder"); + t.is( + r1.downloadUrl, + "https://example.com/mypfx/public/proposal?order_id=myorder", + ); }); -test("taler pay url parsing: complex path prefix and instance", (t) => { +test("taler pay url parsing: complex path prefix and instance", t => { const url1 = "taler://pay/example.com/mypfx%2Fpublic/foo/myorder"; const r1 = parsePayUri(url1); if (!r1) { t.fail(); return; } - t.is(r1.downloadUrl, "https://example.com/mypfx/public/instances/foo/proposal?order_id=myorder"); + t.is( + r1.downloadUrl, + "https://example.com/mypfx/public/instances/foo/proposal?order_id=myorder", + ); }); -test("taler pay url parsing: non-https #1", (t) => { +test("taler pay url parsing: non-https #1", t => { const url1 = "taler://pay/example.com/-/-/myorder?insecure=1"; const r1 = parsePayUri(url1); if (!r1) { @@ -130,7 +142,7 @@ test("taler pay url parsing: non-https #1", (t) => { t.is(r1.downloadUrl, "http://example.com/public/proposal?order_id=myorder"); }); -test("taler pay url parsing: non-https #2", (t) => { +test("taler pay url parsing: non-https #2", t => { const url1 = "taler://pay/example.com/-/-/myorder?insecure=2"; const r1 = parsePayUri(url1); if (!r1) { @@ -140,8 +152,7 @@ test("taler pay url parsing: non-https #2", (t) => { t.is(r1.downloadUrl, "https://example.com/public/proposal?order_id=myorder"); }); - -test("taler withdraw uri parsing", (t) => { +test("taler withdraw uri parsing", t => { const url1 = "taler://withdraw/bank.example.com/-/12345"; const r1 = parseWithdrawUri(url1); if (!r1) { @@ -151,56 +162,69 @@ test("taler withdraw uri parsing", (t) => { t.is(r1.statusUrl, "https://bank.example.com/api/withdraw-operation/12345"); }); - -test("taler refund uri parsing", (t) => { +test("taler refund uri parsing", t => { const url1 = "taler://refund/merchant.example.com/-/-/1234"; const r1 = parseRefundUri(url1); if (!r1) { t.fail(); return; } - t.is(r1.refundUrl, "https://merchant.example.com/public/refund?order_id=1234"); + t.is( + r1.refundUrl, + "https://merchant.example.com/public/refund?order_id=1234", + ); }); - -test("taler refund uri parsing with instance", (t) => { +test("taler refund uri parsing with instance", t => { const url1 = "taler://refund/merchant.example.com/-/myinst/1234"; const r1 = parseRefundUri(url1); if (!r1) { t.fail(); return; } - t.is(r1.refundUrl, "https://merchant.example.com/public/instances/myinst/refund?order_id=1234"); + t.is( + r1.refundUrl, + "https://merchant.example.com/public/instances/myinst/refund?order_id=1234", + ); }); -test("taler tip pickup uri", (t) => { +test("taler tip pickup uri", t => { const url1 = "taler://tip/merchant.example.com/-/-/tipid"; const r1 = parseTipUri(url1); if (!r1) { t.fail(); return; } - t.is(r1.tipPickupUrl, "https://merchant.example.com/public/tip-pickup?tip_id=tipid"); + t.is( + r1.merchantBaseUrl, + "https://merchant.example.com/public/tip-pickup?tip_id=tipid", + ); }); - -test("taler tip pickup uri with instance", (t) => { +test("taler tip pickup uri with instance", t => { const url1 = "taler://tip/merchant.example.com/-/tipm/tipid"; const r1 = parseTipUri(url1); if (!r1) { t.fail(); return; } - t.is(r1.tipPickupUrl, "https://merchant.example.com/public/instances/tipm/tip-pickup?tip_id=tipid"); + t.is( + r1.merchantBaseUrl, + "https://merchant.example.com/public/instances/tipm/", + ); + t.is(r1.merchantTipId, "tipid"); }); - -test("taler tip pickup uri with instance and prefix", (t) => { +test("taler tip pickup uri with instance and prefix", t => { const url1 = "taler://tip/merchant.example.com/my%2fpfx/tipm/tipid"; const r1 = parseTipUri(url1); if (!r1) { t.fail(); return; } - t.is(r1.tipPickupUrl, "https://merchant.example.com/my/pfx/instances/tipm/tip-pickup?tip_id=tipid"); + t.is( + r1.merchantBaseUrl, + "https://merchant.example.com/my/pfx/instances/tipm/", + ); + t.is(r1.merchantTipId, "tipid"); }); diff --git a/src/taleruri.ts b/src/util/taleruri.ts index c810def29..aa6705c07 100644 --- a/src/taleruri.ts +++ b/src/util/taleruri.ts @@ -1,5 +1,5 @@ /* - This file is part of TALER + This file is part of GNU Taler (C) 2019 GNUnet e.V. GNU Taler is free software; you can redistribute it and/or modify it under the @@ -14,9 +14,6 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import URI = require("urijs"); -import { string } from "prop-types"; - export interface PayUriResult { downloadUrl: string; sessionId?: string; @@ -31,58 +28,47 @@ export interface RefundUriResult { } export interface TipUriResult { - tipPickupUrl: string; - tipId: string; - merchantInstance: string; + merchantTipId: string; merchantOrigin: string; + merchantBaseUrl: string; } export function parseWithdrawUri(s: string): WithdrawUriResult | undefined { - const parsedUri = new URI(s); - if (parsedUri.scheme() !== "taler") { - return undefined; - } - if (parsedUri.authority() != "withdraw") { + const pfx = "taler://withdraw/"; + if (!s.startsWith(pfx)) { return undefined; } - let [host, path, withdrawId] = parsedUri.segmentCoded(); + const rest = s.substring(pfx.length); + + let [host, path, withdrawId] = rest.split("/"); if (path === "-") { - path = "/api/withdraw-operation"; + path = "api/withdraw-operation"; } return { - statusUrl: new URI({ protocol: "https", hostname: host, path: path }) - .segmentCoded(withdrawId) - .href(), + statusUrl: `https://${host}/${path}/${withdrawId}`, }; } export function parsePayUri(s: string): PayUriResult | undefined { - const parsedUri = new URI(s); - const query: any = parsedUri.query(true); - if (parsedUri.scheme() === "http" || parsedUri.scheme() === "https") { + if (s.startsWith("https://") || s.startsWith("http://")) { return { downloadUrl: s, sessionId: undefined, }; } - if (parsedUri.scheme() != "taler") { - return undefined; - } - if (parsedUri.authority() != "pay") { + const pfx = "taler://pay/"; + if (!s.startsWith(pfx)) { return undefined; } - let [ - _, - host, - maybePath, - maybeInstance, - orderId, - maybeSessionid, - ] = parsedUri.path().split("/"); + const [path, search] = s.slice(pfx.length).split("?"); + + let [host, maybePath, maybeInstance, orderId, maybeSessionid] = path.split( + "/", + ); if (!host) { return undefined; @@ -107,15 +93,16 @@ export function parsePayUri(s: string): PayUriResult | undefined { } let protocol = "https"; - if (query["insecure"] === "1") { + const searchParams = new URLSearchParams(search); + if (searchParams.get("insecure") === "1") { protocol = "http"; } - const downloadUrl = new URI( - protocol + "://" + host + "/" + decodeURIComponent(maybePath) + maybeInstancePath + "proposal", - ) - .addQuery({ order_id: orderId }) - .href(); + const downloadUrl = + `${protocol}://${host}/` + + decodeURIComponent(maybePath) + + maybeInstancePath + + `proposal?order_id=${orderId}`; return { downloadUrl, @@ -124,15 +111,14 @@ export function parsePayUri(s: string): PayUriResult | undefined { } export function parseTipUri(s: string): TipUriResult | undefined { - const parsedUri = new URI(s); - if (parsedUri.scheme() != "taler") { - return undefined; - } - if (parsedUri.authority() != "tip") { + const pfx = "taler://tip/"; + if (!s.startsWith(pfx)) { return undefined; } - let [_, host, maybePath, maybeInstance, tipId] = parsedUri.path().split("/"); + const path = s.slice(pfx.length); + + let [host, maybePath, maybeInstance, tipId] = path.split("/"); if (!host) { return undefined; @@ -156,34 +142,25 @@ export function parseTipUri(s: string): TipUriResult | undefined { maybeInstancePath = `instances/${maybeInstance}/`; } - const tipPickupUrl = new URI( - "https://" + host + "/" + maybePath + maybeInstancePath + "tip-pickup", - ).addQuery({ tip_id: tipId }).href(); + const merchantBaseUrl = `https://${host}/${maybePath}${maybeInstancePath}`; return { - tipPickupUrl, - tipId: tipId, - merchantInstance: maybeInstance, - merchantOrigin: new URI(tipPickupUrl).origin(), + merchantTipId: tipId, + merchantOrigin: new URL(merchantBaseUrl).origin, + merchantBaseUrl, }; } export function parseRefundUri(s: string): RefundUriResult | undefined { - const parsedUri = new URI(s); - if (parsedUri.scheme() != "taler") { - return undefined; - } - if (parsedUri.authority() != "refund") { + const pfx = "taler://refund/"; + + if (!s.startsWith(pfx)) { return undefined; } - let [ - _, - host, - maybePath, - maybeInstance, - orderId, - ] = parsedUri.path().split("/"); + const path = s.slice(pfx.length); + + let [host, maybePath, maybeInstance, orderId] = path.split("/"); if (!host) { return undefined; @@ -207,11 +184,16 @@ export function parseRefundUri(s: string): RefundUriResult | undefined { maybeInstancePath = `instances/${maybeInstance}/`; } - const refundUrl = new URI( - "https://" + host + "/" + maybePath + maybeInstancePath + "refund", - ) - .addQuery({ order_id: orderId }) - .href(); + const refundUrl = + "https://" + + host + + "/" + + maybePath + + maybeInstancePath + + "refund" + + "?order_id=" + + orderId; + return { refundUrl, }; diff --git a/src/timer.ts b/src/util/timer.ts index d3bb5d485..d3bb5d485 100644 --- a/src/timer.ts +++ b/src/util/timer.ts diff --git a/src/wire.ts b/src/util/wire.ts index c06a30bbd..63b73d864 100644 --- a/src/wire.ts +++ b/src/util/wire.ts @@ -25,7 +25,7 @@ /** * Imports. */ -import * as i18n from "./i18n"; +import * as i18n from "../i18n"; /** * Short summary of the wire information. diff --git a/src/wallet-impl/balance.ts b/src/wallet-impl/balance.ts new file mode 100644 index 000000000..1d8e077af --- /dev/null +++ b/src/wallet-impl/balance.ts @@ -0,0 +1,144 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Imports. + */ +import { + HistoryQuery, + HistoryEvent, + WalletBalance, + WalletBalanceEntry, +} from "../walletTypes"; +import { oneShotIter, runWithWriteTransaction } from "../util/query"; +import { InternalWalletState } from "./state"; +import { Stores, TipRecord, CoinStatus } from "../dbTypes"; +import * as Amounts from "../util/amounts"; +import { AmountJson } from "../util/amounts"; +import { Logger } from "../util/logging"; + +const logger = new Logger("withdraw.ts"); + +/** + * Get detailed balance information, sliced by exchange and by currency. + */ +export async function getBalances( + ws: InternalWalletState, +): Promise<WalletBalance> { + /** + * Add amount to a balance field, both for + * the slicing by exchange and currency. + */ + function addTo( + balance: WalletBalance, + field: keyof WalletBalanceEntry, + amount: AmountJson, + exchange: string, + ): void { + const z = Amounts.getZero(amount.currency); + const balanceIdentity = { + available: z, + paybackAmount: z, + pendingIncoming: z, + pendingPayment: z, + pendingIncomingDirty: z, + pendingIncomingRefresh: z, + pendingIncomingWithdraw: z, + }; + let entryCurr = balance.byCurrency[amount.currency]; + if (!entryCurr) { + balance.byCurrency[amount.currency] = entryCurr = { + ...balanceIdentity, + }; + } + let entryEx = balance.byExchange[exchange]; + if (!entryEx) { + balance.byExchange[exchange] = entryEx = { ...balanceIdentity }; + } + entryCurr[field] = Amounts.add(entryCurr[field], amount).amount; + entryEx[field] = Amounts.add(entryEx[field], amount).amount; + } + + const balanceStore = { + byCurrency: {}, + byExchange: {}, + }; + + await runWithWriteTransaction( + ws.db, + [Stores.coins, Stores.refresh, Stores.reserves, Stores.purchases], + async tx => { + await tx.iter(Stores.coins).forEach(c => { + if (c.suspended) { + return; + } + if (c.status === CoinStatus.Fresh) { + addTo(balanceStore, "available", c.currentAmount, c.exchangeBaseUrl); + } + if (c.status === CoinStatus.Dirty) { + addTo( + balanceStore, + "pendingIncoming", + c.currentAmount, + c.exchangeBaseUrl, + ); + addTo( + balanceStore, + "pendingIncomingDirty", + c.currentAmount, + c.exchangeBaseUrl, + ); + } + }); + await tx.iter(Stores.refresh).forEach(r => { + // Don't count finished refreshes, since the refresh already resulted + // in coins being added to the wallet. + if (r.finished) { + return; + } + addTo( + balanceStore, + "pendingIncoming", + r.valueOutput, + r.exchangeBaseUrl, + ); + addTo( + balanceStore, + "pendingIncomingRefresh", + r.valueOutput, + r.exchangeBaseUrl, + ); + }); + + await tx.iter(Stores.purchases).forEach(t => { + if (t.finished) { + return; + } + for (const c of t.payReq.coins) { + addTo( + balanceStore, + "pendingPayment", + Amounts.parseOrThrow(c.contribution), + c.exchange_url, + ); + } + }); + }, + ); + + logger.trace("computed balances:", balanceStore); + return balanceStore; +} diff --git a/src/wallet-impl/exchanges.ts b/src/wallet-impl/exchanges.ts new file mode 100644 index 000000000..b3677c6c6 --- /dev/null +++ b/src/wallet-impl/exchanges.ts @@ -0,0 +1,401 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { InternalWalletState } from "./state"; +import { + WALLET_CACHE_BREAKER_CLIENT_VERSION, + OperationFailedAndReportedError, +} from "../wallet"; +import { KeysJson, Denomination, ExchangeWireJson } from "../talerTypes"; +import { getTimestampNow, OperationError } from "../walletTypes"; +import { + ExchangeRecord, + ExchangeUpdateStatus, + Stores, + DenominationRecord, + DenominationStatus, + WireFee, +} from "../dbTypes"; +import { + canonicalizeBaseUrl, + extractTalerStamp, + extractTalerStampOrThrow, +} from "../util/helpers"; +import { + oneShotGet, + oneShotPut, + runWithWriteTransaction, + oneShotMutate, +} from "../util/query"; +import * as Amounts from "../util/amounts"; +import { parsePaytoUri } from "../util/payto"; + +async function denominationRecordFromKeys( + ws: InternalWalletState, + exchangeBaseUrl: string, + denomIn: Denomination, +): Promise<DenominationRecord> { + const denomPubHash = await ws.cryptoApi.hashDenomPub(denomIn.denom_pub); + const d: DenominationRecord = { + denomPub: denomIn.denom_pub, + denomPubHash, + exchangeBaseUrl, + feeDeposit: Amounts.parseOrThrow(denomIn.fee_deposit), + feeRefresh: Amounts.parseOrThrow(denomIn.fee_refresh), + feeRefund: Amounts.parseOrThrow(denomIn.fee_refund), + feeWithdraw: Amounts.parseOrThrow(denomIn.fee_withdraw), + isOffered: true, + masterSig: denomIn.master_sig, + stampExpireDeposit: extractTalerStampOrThrow(denomIn.stamp_expire_deposit), + stampExpireLegal: extractTalerStampOrThrow(denomIn.stamp_expire_legal), + stampExpireWithdraw: extractTalerStampOrThrow( + denomIn.stamp_expire_withdraw, + ), + stampStart: extractTalerStampOrThrow(denomIn.stamp_start), + status: DenominationStatus.Unverified, + value: Amounts.parseOrThrow(denomIn.value), + }; + return d; +} + +async function setExchangeError( + ws: InternalWalletState, + baseUrl: string, + err: OperationError, +): Promise<void> { + const mut = (exchange: ExchangeRecord) => { + exchange.lastError = err; + return exchange; + }; + await oneShotMutate(ws.db, Stores.exchanges, baseUrl, mut); +} + +/** + * Fetch the exchange's /keys and update our database accordingly. + * + * Exceptions thrown in this method must be caught and reported + * in the pending operations. + */ +async function updateExchangeWithKeys( + ws: InternalWalletState, + baseUrl: string, +): Promise<void> { + const existingExchangeRecord = await oneShotGet( + ws.db, + Stores.exchanges, + baseUrl, + ); + + if (existingExchangeRecord?.updateStatus != ExchangeUpdateStatus.FETCH_KEYS) { + return; + } + const keysUrl = new URL("keys", baseUrl); + keysUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION); + + let keysResp; + try { + keysResp = await ws.http.get(keysUrl.href); + } catch (e) { + const m = `Fetching keys failed: ${e.message}`; + await setExchangeError(ws, baseUrl, { + type: "network", + details: { + requestUrl: e.config?.url, + }, + message: m, + }); + throw new OperationFailedAndReportedError(m); + } + let exchangeKeysJson: KeysJson; + try { + exchangeKeysJson = KeysJson.checked(keysResp.responseJson); + } catch (e) { + const m = `Parsing /keys response failed: ${e.message}`; + await setExchangeError(ws, baseUrl, { + type: "protocol-violation", + details: {}, + message: m, + }); + throw new OperationFailedAndReportedError(m); + } + + const lastUpdateTimestamp = extractTalerStamp( + exchangeKeysJson.list_issue_date, + ); + if (!lastUpdateTimestamp) { + const m = `Parsing /keys response failed: invalid list_issue_date.`; + await setExchangeError(ws, baseUrl, { + type: "protocol-violation", + details: {}, + message: m, + }); + throw new OperationFailedAndReportedError(m); + } + + if (exchangeKeysJson.denoms.length === 0) { + const m = "exchange doesn't offer any denominations"; + await setExchangeError(ws, baseUrl, { + type: "protocol-violation", + details: {}, + message: m, + }); + throw new OperationFailedAndReportedError(m); + } + + const protocolVersion = exchangeKeysJson.version; + if (!protocolVersion) { + const m = "outdate exchange, no version in /keys response"; + await setExchangeError(ws, baseUrl, { + type: "protocol-violation", + details: {}, + message: m, + }); + throw new OperationFailedAndReportedError(m); + } + + const currency = Amounts.parseOrThrow(exchangeKeysJson.denoms[0].value) + .currency; + + const newDenominations = await Promise.all( + exchangeKeysJson.denoms.map(d => + denominationRecordFromKeys(ws, baseUrl, d), + ), + ); + + await runWithWriteTransaction( + ws.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. + */ +async function updateExchangeWithWireInfo( + ws: InternalWalletState, + exchangeBaseUrl: string, +) { + const exchange = await oneShotGet(ws.db, Stores.exchanges, exchangeBaseUrl); + if (!exchange) { + return; + } + if (exchange.updateStatus != ExchangeUpdateStatus.FETCH_WIRE) { + return; + } + const reqUrl = new URL("wire", exchangeBaseUrl); + reqUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION) + + const resp = await ws.http.get(reqUrl.href); + + const wiJson = resp.responseJson; + if (!wiJson) { + 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(ws.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); + }); +} + +/** + * Update or add exchange DB entry by fetching the /keys and /wire information. + * Optionally link the reserve entry to the new or existing + * exchange entry in then DB. + */ +export async function updateExchangeFromUrl( + ws: InternalWalletState, + baseUrl: string, + force: boolean = false, +): Promise<ExchangeRecord> { + const now = getTimestampNow(); + baseUrl = canonicalizeBaseUrl(baseUrl); + + const r = await oneShotGet(ws.db, Stores.exchanges, baseUrl); + if (!r) { + const newExchangeRecord: ExchangeRecord = { + baseUrl: baseUrl, + details: undefined, + wireInfo: undefined, + updateStatus: ExchangeUpdateStatus.FETCH_KEYS, + updateStarted: now, + updateReason: "initial", + timestampAdded: getTimestampNow(), + }; + await oneShotPut(ws.db, Stores.exchanges, newExchangeRecord); + } else { + await runWithWriteTransaction(ws.db, [Stores.exchanges], async t => { + const rec = await t.get(Stores.exchanges, baseUrl); + if (!rec) { + return; + } + 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); + }); + } + + await updateExchangeWithKeys(ws, baseUrl); + await updateExchangeWithWireInfo(ws, baseUrl); + + const updatedExchange = await oneShotGet(ws.db, Stores.exchanges, baseUrl); + + if (!updatedExchange) { + // This should practically never happen + throw Error("exchange not found"); + } + return updatedExchange; +} + +/** + * Check if and how an exchange is trusted and/or audited. + */ +export async function getExchangeTrust( + ws: InternalWalletState, + exchangeInfo: ExchangeRecord, +): Promise<{ isTrusted: boolean; isAudited: boolean }> { + let isTrusted = false; + let isAudited = false; + const exchangeDetails = exchangeInfo.details; + if (!exchangeDetails) { + throw Error(`exchange ${exchangeInfo.baseUrl} details not available`); + } + const currencyRecord = await oneShotGet( + ws.db, + Stores.currencies, + exchangeDetails.currency, + ); + if (currencyRecord) { + for (const trustedExchange of currencyRecord.exchanges) { + if (trustedExchange.exchangePub === exchangeDetails.masterPublicKey) { + isTrusted = true; + break; + } + } + for (const trustedAuditor of currencyRecord.auditors) { + for (const exchangeAuditor of exchangeDetails.auditors) { + if (trustedAuditor.auditorPub === exchangeAuditor.auditor_pub) { + isAudited = true; + break; + } + } + } + } + return { isTrusted, isAudited }; +} + +export async function getExchangePaytoUri( + ws: InternalWalletState, + exchangeBaseUrl: string, + supportedTargetTypes: string[], +): Promise<string> { + // We do the update here, since the exchange might not even exist + // yet in our database. + const exchangeRecord = await updateExchangeFromUrl(ws, exchangeBaseUrl); + if (!exchangeRecord) { + throw Error(`Exchange '${exchangeBaseUrl}' not found.`); + } + const exchangeWireInfo = exchangeRecord.wireInfo; + if (!exchangeWireInfo) { + throw Error(`Exchange wire info for '${exchangeBaseUrl}' not found.`); + } + for (let account of exchangeWireInfo.accounts) { + const res = parsePaytoUri(account.url); + if (!res) { + continue; + } + if (supportedTargetTypes.includes(res.targetType)) { + return account.url; + } + } + throw Error("no matching exchange account found"); +} diff --git a/src/wallet-impl/history.ts b/src/wallet-impl/history.ts new file mode 100644 index 000000000..976dab885 --- /dev/null +++ b/src/wallet-impl/history.ts @@ -0,0 +1,172 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + + /** + * Imports. + */ +import { HistoryQuery, HistoryEvent } from "../walletTypes"; +import { oneShotIter } from "../util/query"; +import { InternalWalletState } from "./state"; +import { Stores, TipRecord } from "../dbTypes"; +import * as Amounts from "../util/amounts"; +import { AmountJson } from "../util/amounts"; + +/** + * Retrive the full event history for this wallet. + */ +export async function getHistory( + ws: InternalWalletState, + historyQuery?: HistoryQuery, +): Promise<{ history: HistoryEvent[] }> { + const history: HistoryEvent[] = []; + + // FIXME: do pagination instead of generating the full history + + // We uniquely identify history rows via their timestamp. + // This works as timestamps are guaranteed to be monotonically + // increasing even + + const proposals = await oneShotIter(ws.db, Stores.proposals).toArray(); + for (const p of proposals) { + history.push({ + detail: { + contractTermsHash: p.contractTermsHash, + merchantName: p.contractTerms.merchant.name, + }, + timestamp: p.timestamp, + type: "claim-order", + explicit: false, + }); + } + + const withdrawals = await oneShotIter( + ws.db, + Stores.withdrawalSession, + ).toArray(); + for (const w of withdrawals) { + history.push({ + detail: { + withdrawalAmount: w.withdrawalAmount, + }, + timestamp: w.startTimestamp, + type: "withdraw", + explicit: false, + }); + } + + const purchases = await oneShotIter(ws.db, Stores.purchases).toArray(); + for (const p of purchases) { + history.push({ + detail: { + amount: p.contractTerms.amount, + contractTermsHash: p.contractTermsHash, + fulfillmentUrl: p.contractTerms.fulfillment_url, + merchantName: p.contractTerms.merchant.name, + }, + timestamp: p.timestamp, + type: "pay", + explicit: false, + }); + if (p.timestamp_refund) { + const contractAmount = Amounts.parseOrThrow(p.contractTerms.amount); + const amountsPending = Object.keys(p.refundsPending).map(x => + Amounts.parseOrThrow(p.refundsPending[x].refund_amount), + ); + const amountsDone = Object.keys(p.refundsDone).map(x => + Amounts.parseOrThrow(p.refundsDone[x].refund_amount), + ); + const amounts: AmountJson[] = amountsPending.concat(amountsDone); + const amount = Amounts.add( + Amounts.getZero(contractAmount.currency), + ...amounts, + ).amount; + + history.push({ + detail: { + contractTermsHash: p.contractTermsHash, + fulfillmentUrl: p.contractTerms.fulfillment_url, + merchantName: p.contractTerms.merchant.name, + refundAmount: amount, + }, + timestamp: p.timestamp_refund, + type: "refund", + explicit: false, + }); + } + } + + const reserves = await oneShotIter(ws.db, Stores.reserves).toArray(); + + for (const r of reserves) { + const reserveType = r.bankWithdrawStatusUrl ? "taler-bank" : "manual"; + history.push({ + detail: { + exchangeBaseUrl: r.exchangeBaseUrl, + requestedAmount: Amounts.toString(r.initiallyRequestedAmount), + reservePub: r.reservePub, + reserveType, + bankWithdrawStatusUrl: r.bankWithdrawStatusUrl, + }, + timestamp: r.created, + type: "reserve-created", + explicit: false, + }); + if (r.timestampConfirmed) { + history.push({ + detail: { + exchangeBaseUrl: r.exchangeBaseUrl, + requestedAmount: Amounts.toString(r.initiallyRequestedAmount), + reservePub: r.reservePub, + reserveType, + bankWithdrawStatusUrl: r.bankWithdrawStatusUrl, + }, + timestamp: r.created, + type: "reserve-confirmed", + explicit: false, + }); + } + } + + const tips: TipRecord[] = await oneShotIter(ws.db, Stores.tips).toArray(); + for (const tip of tips) { + history.push({ + detail: { + accepted: tip.accepted, + amount: tip.amount, + merchantBaseUrl: tip.merchantBaseUrl, + tipId: tip.merchantTipId, + }, + timestamp: tip.timestamp, + explicit: false, + type: "tip", + }); + } + + await oneShotIter(ws.db, Stores.exchanges).forEach(exchange => { + history.push({ + type: "exchange-added", + explicit: false, + timestamp: exchange.timestampAdded, + detail: { + exchangeBaseUrl: exchange.baseUrl, + }, + }); + }); + + history.sort((h1, h2) => Math.sign(h1.timestamp.t_ms - h2.timestamp.t_ms)); + + return { history }; +} diff --git a/src/wallet-impl/pay.ts b/src/wallet-impl/pay.ts new file mode 100644 index 000000000..d4d2b3cd4 --- /dev/null +++ b/src/wallet-impl/pay.ts @@ -0,0 +1,822 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { AmountJson } from "../util/amounts"; +import { + Auditor, + ExchangeHandle, + MerchantRefundResponse, + PayReq, + Proposal, + ContractTerms, +} from "../talerTypes"; +import { + Timestamp, + CoinSelectionResult, + CoinWithDenom, + PayCoinInfo, + getTimestampNow, + PreparePayResult, + ConfirmPayResult, +} from "../walletTypes"; +import { + oneShotIter, + oneShotIterIndex, + oneShotGet, + runWithWriteTransaction, + oneShotPut, + oneShotGetIndexed, +} from "../util/query"; +import { + Stores, + CoinStatus, + DenominationRecord, + ProposalRecord, + PurchaseRecord, + CoinRecord, + ProposalStatus, +} from "../dbTypes"; +import * as Amounts from "../util/amounts"; +import { + amountToPretty, + strcmp, + extractTalerStamp, + canonicalJson, +} from "../util/helpers"; +import { Logger } from "../util/logging"; +import { InternalWalletState } from "./state"; +import { parsePayUri } from "../util/taleruri"; +import { getTotalRefreshCost, refresh } from "./refresh"; +import { acceptRefundResponse } from "./refund"; +import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto"; + +export interface SpeculativePayData { + payCoinInfo: PayCoinInfo; + exchangeUrl: string; + orderDownloadId: string; + proposal: ProposalRecord; +} + +interface CoinsForPaymentArgs { + allowedAuditors: Auditor[]; + allowedExchanges: ExchangeHandle[]; + depositFeeLimit: AmountJson; + paymentAmount: AmountJson; + wireFeeAmortization: number; + wireFeeLimit: AmountJson; + wireFeeTime: Timestamp; + wireMethod: string; +} + +interface SelectPayCoinsResult { + cds: CoinWithDenom[]; + totalFees: AmountJson; +} + +const logger = new Logger("pay.ts"); + +/** + * Select coins for a payment under the merchant's constraints. + * + * @param denoms all available denoms, used to compute refresh fees + */ +export function selectPayCoins( + denoms: DenominationRecord[], + cds: CoinWithDenom[], + paymentAmount: AmountJson, + depositFeeLimit: AmountJson, +): SelectPayCoinsResult | undefined { + if (cds.length === 0) { + return undefined; + } + // Sort by ascending deposit fee and denomPub if deposit fee is the same + // (to guarantee deterministic results) + cds.sort( + (o1, o2) => + Amounts.cmp(o1.denom.feeDeposit, o2.denom.feeDeposit) || + strcmp(o1.denom.denomPub, o2.denom.denomPub), + ); + const currency = cds[0].denom.value.currency; + const cdsResult: CoinWithDenom[] = []; + let accDepositFee: AmountJson = Amounts.getZero(currency); + let accAmount: AmountJson = Amounts.getZero(currency); + for (const { coin, denom } of cds) { + if (coin.suspended) { + continue; + } + if (coin.status !== CoinStatus.Fresh) { + continue; + } + if (Amounts.cmp(denom.feeDeposit, coin.currentAmount) >= 0) { + continue; + } + cdsResult.push({ coin, denom }); + accDepositFee = Amounts.add(denom.feeDeposit, accDepositFee).amount; + let leftAmount = Amounts.sub( + coin.currentAmount, + Amounts.sub(paymentAmount, accAmount).amount, + ).amount; + accAmount = Amounts.add(coin.currentAmount, accAmount).amount; + const coversAmount = Amounts.cmp(accAmount, paymentAmount) >= 0; + const coversAmountWithFee = + Amounts.cmp( + accAmount, + Amounts.add(paymentAmount, denom.feeDeposit).amount, + ) >= 0; + const isBelowFee = Amounts.cmp(accDepositFee, depositFeeLimit) <= 0; + + logger.trace("candidate coin selection", { + coversAmount, + isBelowFee, + accDepositFee, + accAmount, + paymentAmount, + }); + + if ((coversAmount && isBelowFee) || coversAmountWithFee) { + const depositFeeToCover = Amounts.sub(accDepositFee, depositFeeLimit) + .amount; + leftAmount = Amounts.sub(leftAmount, depositFeeToCover).amount; + logger.trace("deposit fee to cover", amountToPretty(depositFeeToCover)); + let totalFees: AmountJson = Amounts.getZero(currency); + if (coversAmountWithFee && !isBelowFee) { + // these are the fees the customer has to pay + // because the merchant doesn't cover them + totalFees = Amounts.sub(depositFeeLimit, accDepositFee).amount; + } + totalFees = Amounts.add( + totalFees, + getTotalRefreshCost(denoms, denom, leftAmount), + ).amount; + return { cds: cdsResult, totalFees }; + } + } + return undefined; +} + +/** + * Get exchanges and associated coins that are still spendable, but only + * if the sum the coins' remaining value covers the payment amount and fees. + */ +async function getCoinsForPayment( + ws: InternalWalletState, + args: CoinsForPaymentArgs, +): Promise<CoinSelectionResult | undefined> { + const { + allowedAuditors, + allowedExchanges, + depositFeeLimit, + paymentAmount, + wireFeeAmortization, + wireFeeLimit, + wireFeeTime, + wireMethod, + } = args; + + let remainingAmount = paymentAmount; + + const exchanges = await oneShotIter(ws.db, Stores.exchanges).toArray(); + + for (const exchange of exchanges) { + let isOkay: boolean = false; + const exchangeDetails = exchange.details; + if (!exchangeDetails) { + continue; + } + const exchangeFees = exchange.wireInfo; + if (!exchangeFees) { + continue; + } + + // is the exchange explicitly allowed? + for (const allowedExchange of allowedExchanges) { + if (allowedExchange.master_pub === exchangeDetails.masterPublicKey) { + isOkay = true; + break; + } + } + + // is the exchange allowed because of one of its auditors? + if (!isOkay) { + for (const allowedAuditor of allowedAuditors) { + for (const auditor of exchangeDetails.auditors) { + if (auditor.auditor_pub === allowedAuditor.auditor_pub) { + isOkay = true; + break; + } + } + if (isOkay) { + break; + } + } + } + + if (!isOkay) { + continue; + } + + const coins = await oneShotIterIndex( + ws.db, + Stores.coins.exchangeBaseUrlIndex, + exchange.baseUrl, + ).toArray(); + + const denoms = await oneShotIterIndex( + ws.db, + Stores.denominations.exchangeBaseUrlIndex, + exchange.baseUrl, + ).toArray(); + + if (!coins || coins.length === 0) { + continue; + } + + // Denomination of the first coin, we assume that all other + // coins have the same currency + const firstDenom = await oneShotGet(ws.db, Stores.denominations, [ + exchange.baseUrl, + coins[0].denomPub, + ]); + if (!firstDenom) { + throw Error("db inconsistent"); + } + const currency = firstDenom.value.currency; + const cds: CoinWithDenom[] = []; + for (const coin of coins) { + const denom = await oneShotGet(ws.db, Stores.denominations, [ + exchange.baseUrl, + coin.denomPub, + ]); + if (!denom) { + throw Error("db inconsistent"); + } + if (denom.value.currency !== currency) { + console.warn( + `same pubkey for different currencies at exchange ${exchange.baseUrl}`, + ); + continue; + } + if (coin.suspended) { + continue; + } + if (coin.status !== CoinStatus.Fresh) { + continue; + } + cds.push({ coin, denom }); + } + + let totalFees = Amounts.getZero(currency); + let wireFee: AmountJson | undefined; + for (const fee of exchangeFees.feesForType[wireMethod] || []) { + if (fee.startStamp <= wireFeeTime && fee.endStamp >= wireFeeTime) { + wireFee = fee.wireFee; + break; + } + } + + if (wireFee) { + const amortizedWireFee = Amounts.divide(wireFee, wireFeeAmortization); + if (Amounts.cmp(wireFeeLimit, amortizedWireFee) < 0) { + totalFees = Amounts.add(amortizedWireFee, totalFees).amount; + remainingAmount = Amounts.add(amortizedWireFee, remainingAmount).amount; + } + } + + const res = selectPayCoins(denoms, cds, remainingAmount, depositFeeLimit); + + if (res) { + totalFees = Amounts.add(totalFees, res.totalFees).amount; + return { + cds: res.cds, + exchangeUrl: exchange.baseUrl, + totalAmount: remainingAmount, + totalFees, + }; + } + } + return undefined; +} + +/** + * Record all information that is necessary to + * pay for a proposal in the wallet's database. + */ +async function recordConfirmPay( + ws: InternalWalletState, + proposal: ProposalRecord, + payCoinInfo: PayCoinInfo, + chosenExchange: string, +): Promise<PurchaseRecord> { + const payReq: PayReq = { + coins: payCoinInfo.sigs, + merchant_pub: proposal.contractTerms.merchant_pub, + mode: "pay", + order_id: proposal.contractTerms.order_id, + }; + const t: PurchaseRecord = { + abortDone: false, + abortRequested: false, + contractTerms: proposal.contractTerms, + contractTermsHash: proposal.contractTermsHash, + finished: false, + lastSessionId: undefined, + merchantSig: proposal.merchantSig, + payReq, + refundsDone: {}, + refundsPending: {}, + timestamp: getTimestampNow(), + timestamp_refund: undefined, + }; + + await runWithWriteTransaction( + ws.db, + [Stores.coins, Stores.purchases], + async tx => { + await tx.put(Stores.purchases, t); + for (let c of payCoinInfo.updatedCoins) { + await tx.put(Stores.coins, c); + } + }, + ); + + ws.badge.showNotification(); + ws.notifier.notify(); + return t; +} + +function getNextUrl(contractTerms: ContractTerms): string { + const fu = new URL(contractTerms.fulfillment_url) + fu.searchParams.set("order_id", contractTerms.order_id); + return fu.href; +} + +export async function abortFailedPayment( + ws: InternalWalletState, + contractTermsHash: string, +): Promise<void> { + const purchase = await oneShotGet(ws.db, Stores.purchases, contractTermsHash); + if (!purchase) { + throw Error("Purchase not found, unable to abort with refund"); + } + if (purchase.finished) { + throw Error("Purchase already finished, not aborting"); + } + if (purchase.abortDone) { + console.warn("abort requested on already aborted purchase"); + return; + } + + purchase.abortRequested = true; + + // From now on, we can't retry payment anymore, + // so mark this in the DB in case the /pay abort + // does not complete on the first try. + await oneShotPut(ws.db, Stores.purchases, purchase); + + let resp; + + const abortReq = { ...purchase.payReq, mode: "abort-refund" }; + + const payUrl = new URL("pay", purchase.contractTerms.merchant_base_url).href; + + try { + resp = await ws.http.postJson(payUrl, abortReq); + } catch (e) { + // Gives the user the option to retry / abort and refresh + console.log("aborting payment failed", e); + throw e; + } + + const refundResponse = MerchantRefundResponse.checked(resp.responseJson); + await acceptRefundResponse(ws, refundResponse); + + await runWithWriteTransaction(ws.db, [Stores.purchases], async tx => { + const p = await tx.get(Stores.purchases, purchase.contractTermsHash); + if (!p) { + return; + } + p.abortDone = true; + await tx.put(Stores.purchases, p); + }); +} + +/** + * Download a proposal and store it in the database. + * Returns an id for it to retrieve it later. + * + * @param sessionId Current session ID, if the proposal is being + * downloaded in the context of a session ID. + */ +async function downloadProposal( + ws: InternalWalletState, + url: string, + sessionId?: string, +): Promise<string> { + const oldProposal = await oneShotGetIndexed( + ws.db, + Stores.proposals.urlIndex, + url, + ); + if (oldProposal) { + return oldProposal.proposalId; + } + + const { priv, pub } = await ws.cryptoApi.createEddsaKeypair(); + const parsed_url = new URL(url); + parsed_url.searchParams.set("nonce", pub); + const urlWithNonce = parsed_url.href; + console.log("downloading contract from '" + urlWithNonce + "'"); + let resp; + try { + resp = await ws.http.get(urlWithNonce); + } catch (e) { + console.log("contract download failed", e); + throw e; + } + + const proposal = Proposal.checked(resp.responseJson); + + const contractTermsHash = await ws.cryptoApi.hashString( + canonicalJson(proposal.contract_terms), + ); + + const proposalId = encodeCrock(getRandomBytes(32)); + + const proposalRecord: ProposalRecord = { + contractTerms: proposal.contract_terms, + contractTermsHash, + merchantSig: proposal.sig, + noncePriv: priv, + timestamp: getTimestampNow(), + url, + downloadSessionId: sessionId, + proposalId: proposalId, + proposalStatus: ProposalStatus.PROPOSED, + }; + await oneShotPut(ws.db, Stores.proposals, proposalRecord); + ws.notifier.notify(); + + return proposalId; +} + +async function submitPay( + ws: InternalWalletState, + contractTermsHash: string, + sessionId: string | undefined, +): Promise<ConfirmPayResult> { + const purchase = await oneShotGet(ws.db, Stores.purchases, contractTermsHash); + if (!purchase) { + throw Error("Purchase not found: " + contractTermsHash); + } + if (purchase.abortRequested) { + throw Error("not submitting payment for aborted purchase"); + } + let resp; + const payReq = { ...purchase.payReq, session_id: sessionId }; + + const payUrl = new URL("pay", purchase.contractTerms.merchant_base_url).href; + + try { + resp = await ws.http.postJson(payUrl, payReq); + } catch (e) { + // Gives the user the option to retry / abort and refresh + console.log("payment failed", e); + throw e; + } + const merchantResp = resp.responseJson; + console.log("got success from pay URL"); + + const merchantPub = purchase.contractTerms.merchant_pub; + const valid: boolean = await ws.cryptoApi.isValidPaymentSignature( + merchantResp.sig, + contractTermsHash, + merchantPub, + ); + if (!valid) { + console.error("merchant payment signature invalid"); + // FIXME: properly display error + throw Error("merchant payment signature invalid"); + } + purchase.finished = true; + const modifiedCoins: CoinRecord[] = []; + for (const pc of purchase.payReq.coins) { + const c = await oneShotGet(ws.db, Stores.coins, pc.coin_pub); + if (!c) { + console.error("coin not found"); + throw Error("coin used in payment not found"); + } + c.status = CoinStatus.Dirty; + modifiedCoins.push(c); + } + + await runWithWriteTransaction( + ws.db, + [Stores.coins, Stores.purchases], + async tx => { + for (let c of modifiedCoins) { + tx.put(Stores.coins, c); + } + tx.put(Stores.purchases, purchase); + }, + ); + + for (const c of purchase.payReq.coins) { + refresh(ws, c.coin_pub); + } + + const nextUrl = getNextUrl(purchase.contractTerms); + ws.cachedNextUrl[purchase.contractTerms.fulfillment_url] = { + nextUrl, + lastSessionId: sessionId, + }; + + return { nextUrl }; +} + +/** + * Check if a payment for the given taler://pay/ URI is possible. + * + * If the payment is possible, the signature are already generated but not + * yet send to the merchant. + */ +export async function preparePay( + ws: InternalWalletState, + talerPayUri: string, +): Promise<PreparePayResult> { + const uriResult = parsePayUri(talerPayUri); + + if (!uriResult) { + return { + status: "error", + error: "URI not supported", + }; + } + + let proposalId: string; + try { + proposalId = await downloadProposal( + ws, + uriResult.downloadUrl, + uriResult.sessionId, + ); + } catch (e) { + return { + status: "error", + error: e.toString(), + }; + } + const proposal = await oneShotGet(ws.db, Stores.proposals, proposalId); + if (!proposal) { + throw Error(`could not get proposal ${proposalId}`); + } + + console.log("proposal", proposal); + + const differentPurchase = await oneShotGetIndexed( + ws.db, + Stores.purchases.fulfillmentUrlIndex, + proposal.contractTerms.fulfillment_url, + ); + + 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. + if ( + differentPurchase.contractTerms.merchant_pub != + proposal.contractTerms.merchant_pub + ) { + console.warn( + "merchant with different public key offered contract with same fulfillment URL as an existing purchase", + ); + } else { + if (uriResult.sessionId) { + await submitPay( + ws, + differentPurchase.contractTermsHash, + uriResult.sessionId, + ); + } + return { + status: "paid", + contractTerms: differentPurchase.contractTerms, + nextUrl: getNextUrl(differentPurchase.contractTerms), + }; + } + } + + // First check if we already payed for it. + const purchase = await oneShotGet( + ws.db, + Stores.purchases, + proposal.contractTermsHash, + ); + + if (!purchase) { + const paymentAmount = Amounts.parseOrThrow(proposal.contractTerms.amount); + let wireFeeLimit; + if (proposal.contractTerms.max_wire_fee) { + wireFeeLimit = Amounts.parseOrThrow(proposal.contractTerms.max_wire_fee); + } else { + wireFeeLimit = Amounts.getZero(paymentAmount.currency); + } + // If not already payed, check if we could pay for it. + const res = await getCoinsForPayment(ws, { + allowedAuditors: proposal.contractTerms.auditors, + allowedExchanges: proposal.contractTerms.exchanges, + depositFeeLimit: Amounts.parseOrThrow(proposal.contractTerms.max_fee), + paymentAmount, + wireFeeAmortization: proposal.contractTerms.wire_fee_amortization || 1, + wireFeeLimit, + // FIXME: parse this properly + wireFeeTime: extractTalerStamp(proposal.contractTerms.timestamp) || { + t_ms: 0, + }, + wireMethod: proposal.contractTerms.wire_method, + }); + + if (!res) { + console.log("not confirming payment, insufficient coins"); + return { + status: "insufficient-balance", + contractTerms: proposal.contractTerms, + proposalId: proposal.proposalId, + }; + } + + // Only create speculative signature if we don't already have one for this proposal + if ( + !ws.speculativePayData || + (ws.speculativePayData && + ws.speculativePayData.orderDownloadId !== proposalId) + ) { + const { exchangeUrl, cds, totalAmount } = res; + const payCoinInfo = await ws.cryptoApi.signDeposit( + proposal.contractTerms, + cds, + totalAmount, + ); + ws.speculativePayData = { + exchangeUrl, + payCoinInfo, + proposal, + orderDownloadId: proposalId, + }; + logger.trace("created speculative pay data for payment"); + } + + return { + status: "payment-possible", + contractTerms: proposal.contractTerms, + proposalId: proposal.proposalId, + totalFees: res.totalFees, + }; + } + + if (uriResult.sessionId) { + await submitPay(ws, purchase.contractTermsHash, uriResult.sessionId); + } + + return { + status: "paid", + contractTerms: proposal.contractTerms, + nextUrl: getNextUrl(purchase.contractTerms), + }; +} + +/** + * Get the speculative pay data, but only if coins have not changed in between. + */ +async function getSpeculativePayData( + ws: InternalWalletState, + proposalId: string, +): Promise<SpeculativePayData | undefined> { + const sp = ws.speculativePayData; + if (!sp) { + return; + } + if (sp.orderDownloadId !== proposalId) { + return; + } + const coinKeys = sp.payCoinInfo.updatedCoins.map(x => x.coinPub); + const coins: CoinRecord[] = []; + for (let coinKey of coinKeys) { + const cc = await oneShotGet(ws.db, Stores.coins, coinKey); + if (cc) { + coins.push(cc); + } + } + for (let i = 0; i < coins.length; i++) { + const specCoin = sp.payCoinInfo.originalCoins[i]; + const currentCoin = coins[i]; + + // Coin does not exist anymore! + if (!currentCoin) { + return; + } + if (Amounts.cmp(specCoin.currentAmount, currentCoin.currentAmount) !== 0) { + return; + } + } + return sp; +} + +/** + * Add a contract to the wallet and sign coins, and send them. + */ +export async function confirmPay( + ws: InternalWalletState, + proposalId: string, + sessionIdOverride: string | undefined, +): Promise<ConfirmPayResult> { + logger.trace( + `executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`, + ); + const proposal = await oneShotGet(ws.db, Stores.proposals, proposalId); + + if (!proposal) { + throw Error(`proposal with id ${proposalId} not found`); + } + + const sessionId = sessionIdOverride || proposal.downloadSessionId; + + let purchase = await oneShotGet( + ws.db, + Stores.purchases, + proposal.contractTermsHash, + ); + + if (purchase) { + return submitPay(ws, purchase.contractTermsHash, sessionId); + } + + const contractAmount = Amounts.parseOrThrow(proposal.contractTerms.amount); + + let wireFeeLimit; + if (!proposal.contractTerms.max_wire_fee) { + wireFeeLimit = Amounts.getZero(contractAmount.currency); + } else { + wireFeeLimit = Amounts.parseOrThrow(proposal.contractTerms.max_wire_fee); + } + + const res = await getCoinsForPayment(ws, { + allowedAuditors: proposal.contractTerms.auditors, + allowedExchanges: proposal.contractTerms.exchanges, + depositFeeLimit: Amounts.parseOrThrow(proposal.contractTerms.max_fee), + paymentAmount: Amounts.parseOrThrow(proposal.contractTerms.amount), + wireFeeAmortization: proposal.contractTerms.wire_fee_amortization || 1, + wireFeeLimit, + // FIXME: parse this properly + wireFeeTime: extractTalerStamp(proposal.contractTerms.timestamp) || { + t_ms: 0, + }, + wireMethod: proposal.contractTerms.wire_method, + }); + + logger.trace("coin selection result", res); + + if (!res) { + // Should not happen, since checkPay should be called first + console.log("not confirming payment, insufficient coins"); + throw Error("insufficient balance"); + } + + const sd = await getSpeculativePayData(ws, proposalId); + if (!sd) { + const { exchangeUrl, cds, totalAmount } = res; + const payCoinInfo = await ws.cryptoApi.signDeposit( + proposal.contractTerms, + cds, + totalAmount, + ); + purchase = await recordConfirmPay(ws, proposal, payCoinInfo, exchangeUrl); + } else { + purchase = await recordConfirmPay( + ws, + sd.proposal, + sd.payCoinInfo, + sd.exchangeUrl, + ); + } + + return submitPay(ws, purchase.contractTermsHash, sessionId); +} diff --git a/src/wallet-impl/payback.ts b/src/wallet-impl/payback.ts new file mode 100644 index 000000000..5bf5ff06e --- /dev/null +++ b/src/wallet-impl/payback.ts @@ -0,0 +1,88 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Imports. + */ +import { + oneShotIter, + runWithWriteTransaction, + oneShotGet, + oneShotPut, +} from "../util/query"; +import { InternalWalletState } from "./state"; +import { Stores, TipRecord, CoinStatus } from "../dbTypes"; + +import { Logger } from "../util/logging"; +import { PaybackConfirmation } from "../talerTypes"; +import { updateExchangeFromUrl } from "./exchanges"; + +const logger = new Logger("payback.ts"); + +export async function payback( + ws: InternalWalletState, + coinPub: string, +): Promise<void> { + let coin = await oneShotGet(ws.db, Stores.coins, coinPub); + if (!coin) { + throw Error(`Coin ${coinPub} not found, can't request payback`); + } + const reservePub = coin.reservePub; + if (!reservePub) { + throw Error(`Can't request payback for a refreshed coin`); + } + const reserve = await oneShotGet(ws.db, Stores.reserves, reservePub); + if (!reserve) { + throw Error(`Reserve of coin ${coinPub} not found`); + } + switch (coin.status) { + case CoinStatus.Dormant: + throw Error(`Can't do payback for coin ${coinPub} since it's dormant`); + } + 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. + reserve.hasPayback = true; + await runWithWriteTransaction( + ws.db, + [Stores.coins, Stores.reserves], + async tx => { + await tx.put(Stores.coins, coin!!); + await tx.put(Stores.reserves, reserve); + }, + ); + ws.notifier.notify(); + + const paybackRequest = await ws.cryptoApi.createPaybackRequest(coin); + const reqUrl = new URL("payback", coin.exchangeBaseUrl); + const resp = await ws.http.postJson(reqUrl.href, paybackRequest); + if (resp.status !== 200) { + throw Error(); + } + const paybackConfirmation = PaybackConfirmation.checked(resp.responseJson); + if (paybackConfirmation.reserve_pub !== coin.reservePub) { + throw Error(`Coin's reserve doesn't match reserve on payback`); + } + coin = await oneShotGet(ws.db, Stores.coins, coinPub); + if (!coin) { + throw Error(`Coin ${coinPub} not found, can't confirm payback`); + } + coin.status = CoinStatus.Dormant; + await oneShotPut(ws.db, Stores.coins, coin); + ws.notifier.notify(); + await updateExchangeFromUrl(ws, coin.exchangeBaseUrl, true); +} diff --git a/src/wallet-impl/pending.ts b/src/wallet-impl/pending.ts new file mode 100644 index 000000000..a66571a34 --- /dev/null +++ b/src/wallet-impl/pending.ts @@ -0,0 +1,208 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + + /** + * Imports. + */ +import { PendingOperationInfo, PendingOperationsResponse } from "../walletTypes"; +import { oneShotIter } from "../util/query"; +import { InternalWalletState } from "./state"; +import { Stores, ExchangeUpdateStatus, ReserveRecordStatus, CoinStatus, ProposalStatus } from "../dbTypes"; + +export async function getPendingOperations( + ws: InternalWalletState, +): Promise<PendingOperationsResponse> { + const pendingOperations: PendingOperationInfo[] = []; + const exchanges = await oneShotIter(ws.db, Stores.exchanges).toArray(); + for (let e of exchanges) { + switch (e.updateStatus) { + 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", + message: + "Exchange record does not have details, but no update in progress.", + details: { + exchangeBaseUrl: e.baseUrl, + }, + }); + } + 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: + pendingOperations.push({ + 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(ws.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: + 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: + pendingOperations.push({ + type: "bug", + message: "Unknown reserve record status", + details: { + reservePub: reserve.reservePub, + reserveStatus: reserve.reserveStatus, + }, + }); + break; + } + }); + + await oneShotIter(ws.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, + refreshSessionId: r.refreshSessionId, + }); + }); + + await oneShotIter(ws.db, Stores.coins).forEach(coin => { + if (coin.status == CoinStatus.Dirty) { + pendingOperations.push({ + type: "dirty-coin", + coinPub: coin.coinPub, + }); + } + }); + + await oneShotIter(ws.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, + source: ws.source, + withdrawSessionId: ws.withdrawSessionId, + }); + } + }); + + await oneShotIter(ws.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, + }); + } + }); + + await oneShotIter(ws.db, Stores.tips).forEach(tip => { + if (tip.accepted && !tip.pickedUp) { + pendingOperations.push({ + type: "tip", + merchantBaseUrl: tip.merchantBaseUrl, + tipId: tip.tipId, + merchantTipId: tip.merchantTipId, + }); + } + }); + + return { + pendingOperations, + }; +} diff --git a/src/wallet-impl/refresh.ts b/src/wallet-impl/refresh.ts new file mode 100644 index 000000000..7e7270ed3 --- /dev/null +++ b/src/wallet-impl/refresh.ts @@ -0,0 +1,416 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { AmountJson } from "../util/amounts"; +import * as Amounts from "../util/amounts"; +import { + DenominationRecord, + Stores, + CoinStatus, + RefreshPlanchetRecord, + CoinRecord, + RefreshSessionRecord, +} from "../dbTypes"; +import { amountToPretty } from "../util/helpers"; +import { + oneShotGet, + oneShotMutate, + runWithWriteTransaction, + TransactionAbort, + oneShotIterIndex, +} from "../util/query"; +import { InternalWalletState } from "./state"; +import { Logger } from "../util/logging"; +import { getWithdrawDenomList } from "./withdraw"; +import { updateExchangeFromUrl } from "./exchanges"; + +const logger = new Logger("refresh.ts"); + +/** + * Get the amount that we lose when refreshing a coin of the given denomination + * with a certain amount left. + * + * If the amount left is zero, then the refresh cost + * is also considered to be zero. If a refresh isn't possible (e.g. due to lack of + * the right denominations), then the cost is the full amount left. + * + * Considers refresh fees, withdrawal fees after refresh and amounts too small + * to refresh. + */ +export function getTotalRefreshCost( + denoms: DenominationRecord[], + refreshedDenom: DenominationRecord, + amountLeft: AmountJson, +): AmountJson { + const withdrawAmount = Amounts.sub(amountLeft, refreshedDenom.feeRefresh) + .amount; + const withdrawDenoms = getWithdrawDenomList(withdrawAmount, denoms); + const resultingAmount = Amounts.add( + Amounts.getZero(withdrawAmount.currency), + ...withdrawDenoms.map(d => d.value), + ).amount; + const totalCost = Amounts.sub(amountLeft, resultingAmount).amount; + logger.trace( + "total refresh cost for", + amountToPretty(amountLeft), + "is", + amountToPretty(totalCost), + ); + return totalCost; +} + +async function refreshMelt( + ws: InternalWalletState, + refreshSessionId: string, +): Promise<void> { + const refreshSession = await oneShotGet( + ws.db, + Stores.refresh, + refreshSessionId, + ); + if (!refreshSession) { + return; + } + if (refreshSession.norevealIndex !== undefined) { + return; + } + + const coin = await oneShotGet( + ws.db, + Stores.coins, + refreshSession.meltCoinPub, + ); + + if (!coin) { + console.error("can't melt coin, it does not exist"); + return; + } + + const reqUrl = new URL("refresh/melt", refreshSession.exchangeBaseUrl); + const meltReq = { + coin_pub: coin.coinPub, + confirm_sig: refreshSession.confirmSig, + denom_pub_hash: coin.denomPubHash, + denom_sig: coin.denomSig, + rc: refreshSession.hash, + value_with_fee: refreshSession.valueWithFee, + }; + logger.trace("melt request:", meltReq); + const resp = await ws.http.postJson(reqUrl.href, meltReq); + + logger.trace("melt response:", resp.responseJson); + + if (resp.status !== 200) { + console.error(resp.responseJson); + throw Error("refresh failed"); + } + + const respJson = resp.responseJson; + + const norevealIndex = respJson.noreveal_index; + + if (typeof norevealIndex !== "number") { + throw Error("invalid response"); + } + + refreshSession.norevealIndex = norevealIndex; + + await oneShotMutate(ws.db, Stores.refresh, refreshSessionId, rs => { + if (rs.norevealIndex !== undefined) { + return; + } + if (rs.finished) { + return; + } + rs.norevealIndex = norevealIndex; + return rs; + }); + + ws.notifier.notify(); +} + +async function refreshReveal( + ws: InternalWalletState, + refreshSessionId: string, +): Promise<void> { + const refreshSession = await oneShotGet( + ws.db, + Stores.refresh, + refreshSessionId, + ); + if (!refreshSession) { + return; + } + const norevealIndex = refreshSession.norevealIndex; + if (norevealIndex === undefined) { + throw Error("can't reveal without melting first"); + } + const privs = Array.from(refreshSession.transferPrivs); + privs.splice(norevealIndex, 1); + + const planchets = refreshSession.planchetsForGammas[norevealIndex]; + if (!planchets) { + throw Error("refresh index error"); + } + + const meltCoinRecord = await oneShotGet( + ws.db, + Stores.coins, + refreshSession.meltCoinPub, + ); + if (!meltCoinRecord) { + throw Error("inconsistent database"); + } + + const evs = planchets.map((x: RefreshPlanchetRecord) => x.coinEv); + + const linkSigs: string[] = []; + for (let i = 0; i < refreshSession.newDenoms.length; i++) { + const linkSig = await ws.cryptoApi.signCoinLink( + meltCoinRecord.coinPriv, + refreshSession.newDenomHashes[i], + refreshSession.meltCoinPub, + refreshSession.transferPubs[norevealIndex], + planchets[i].coinEv, + ); + linkSigs.push(linkSig); + } + + const req = { + coin_evs: evs, + new_denoms_h: refreshSession.newDenomHashes, + rc: refreshSession.hash, + transfer_privs: privs, + transfer_pub: refreshSession.transferPubs[norevealIndex], + link_sigs: linkSigs, + }; + + const reqUrl = new URL("refresh/reveal", refreshSession.exchangeBaseUrl); + logger.trace("reveal request:", req); + + let resp; + try { + resp = await ws.http.postJson(reqUrl.href, req); + } catch (e) { + console.error("got error during /refresh/reveal request"); + console.error(e); + return; + } + + logger.trace("session:", refreshSession); + logger.trace("reveal response:", resp); + + if (resp.status !== 200) { + console.error("error: /refresh/reveal returned status " + resp.status); + return; + } + + const respJson = resp.responseJson; + + if (!respJson.ev_sigs || !Array.isArray(respJson.ev_sigs)) { + console.error("/refresh/reveal did not contain ev_sigs"); + return; + } + + const exchange = oneShotGet( + ws.db, + Stores.exchanges, + refreshSession.exchangeBaseUrl, + ); + if (!exchange) { + console.error(`exchange ${refreshSession.exchangeBaseUrl} not found`); + return; + } + + const coins: CoinRecord[] = []; + + for (let i = 0; i < respJson.ev_sigs.length; i++) { + const denom = await oneShotGet(ws.db, Stores.denominations, [ + refreshSession.exchangeBaseUrl, + refreshSession.newDenoms[i], + ]); + if (!denom) { + console.error("denom not found"); + continue; + } + const pc = + refreshSession.planchetsForGammas[refreshSession.norevealIndex!][i]; + const denomSig = await ws.cryptoApi.rsaUnblind( + respJson.ev_sigs[i].ev_sig, + pc.blindingKey, + denom.denomPub, + ); + const coin: CoinRecord = { + blindingKey: pc.blindingKey, + coinPriv: pc.privateKey, + coinPub: pc.publicKey, + currentAmount: denom.value, + denomPub: denom.denomPub, + denomPubHash: denom.denomPubHash, + denomSig, + exchangeBaseUrl: refreshSession.exchangeBaseUrl, + reservePub: undefined, + status: CoinStatus.Fresh, + coinIndex: -1, + withdrawSessionId: "", + }; + + coins.push(coin); + } + + refreshSession.finished = true; + + await runWithWriteTransaction( + ws.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); + } + await tx.put(Stores.refresh, refreshSession); + }, + ); + ws.notifier.notify(); +} + +export async function processRefreshSession( + ws: InternalWalletState, + refreshSessionId: string, +) { + const refreshSession = await oneShotGet( + ws.db, + Stores.refresh, + refreshSessionId, + ); + if (!refreshSession) { + return; + } + if (refreshSession.finished) { + return; + } + if (typeof refreshSession.norevealIndex !== "number") { + await refreshMelt(ws, refreshSession.refreshSessionId); + } + await refreshReveal(ws, refreshSession.refreshSessionId); + logger.trace("refresh finished"); +} + +export async function refresh( + ws: InternalWalletState, + oldCoinPub: string, + force: boolean = false, +): Promise<void> { + const coin = await oneShotGet(ws.db, Stores.coins, oldCoinPub); + if (!coin) { + console.warn("can't refresh, coin not in database"); + return; + } + switch (coin.status) { + case CoinStatus.Dirty: + break; + case CoinStatus.Dormant: + return; + case CoinStatus.Fresh: + if (!force) { + return; + } + break; + } + + const exchange = await updateExchangeFromUrl(ws, coin.exchangeBaseUrl); + if (!exchange) { + throw Error("db inconsistent: exchange of coin not found"); + } + + const oldDenom = await oneShotGet(ws.db, Stores.denominations, [ + exchange.baseUrl, + coin.denomPub, + ]); + + if (!oldDenom) { + throw Error("db inconsistent: denomination for coin not found"); + } + + const availableDenoms: DenominationRecord[] = await oneShotIterIndex( + ws.db, + Stores.denominations.exchangeBaseUrlIndex, + exchange.baseUrl, + ).toArray(); + + const availableAmount = Amounts.sub(coin.currentAmount, oldDenom.feeRefresh) + .amount; + + const newCoinDenoms = getWithdrawDenomList(availableAmount, availableDenoms); + + if (newCoinDenoms.length === 0) { + logger.trace( + `not refreshing, available amount ${amountToPretty( + availableAmount, + )} too small`, + ); + await oneShotMutate(ws.db, Stores.coins, oldCoinPub, x => { + if (x.status != coin.status) { + // Concurrent modification? + return; + } + x.status = CoinStatus.Dormant; + return x; + }); + ws.notifier.notify(); + return; + } + + const refreshSession: RefreshSessionRecord = await ws.cryptoApi.createRefreshSession( + exchange.baseUrl, + 3, + coin, + newCoinDenoms, + oldDenom.feeRefresh, + ); + + function mutateCoin(c: CoinRecord): CoinRecord { + const r = Amounts.sub(c.currentAmount, refreshSession.valueWithFee); + if (r.saturated) { + // Something else must have written the coin value + throw TransactionAbort; + } + c.currentAmount = r.amount; + c.status = CoinStatus.Dormant; + return c; + } + + // Store refresh session and subtract refreshed amount from + // coin in the same transaction. + await runWithWriteTransaction( + ws.db, + [Stores.refresh, Stores.coins], + async tx => { + await tx.put(Stores.refresh, refreshSession); + await tx.mutate(Stores.coins, coin.coinPub, mutateCoin); + }, + ); + logger.info(`created refresh session ${refreshSession.refreshSessionId}`); + ws.notifier.notify(); + + await processRefreshSession(ws, refreshSession.refreshSessionId); +} diff --git a/src/wallet-impl/refund.ts b/src/wallet-impl/refund.ts new file mode 100644 index 000000000..2a9dea149 --- /dev/null +++ b/src/wallet-impl/refund.ts @@ -0,0 +1,245 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + MerchantRefundResponse, + RefundRequest, + MerchantRefundPermission, +} from "../talerTypes"; +import { PurchaseRecord, Stores, CoinRecord, CoinStatus } from "../dbTypes"; +import { getTimestampNow } from "../walletTypes"; +import { + oneShotMutate, + oneShotGet, + runWithWriteTransaction, + oneShotIterIndex, +} from "../util/query"; +import { InternalWalletState } from "./state"; +import { parseRefundUri } from "../util/taleruri"; +import { Logger } from "../util/logging"; +import { AmountJson } from "../util/amounts"; +import * as Amounts from "../util/amounts"; +import { getTotalRefreshCost, refresh } from "./refresh"; + +const logger = new Logger("refund.ts"); + +export async function getFullRefundFees( + ws: InternalWalletState, + refundPermissions: MerchantRefundPermission[], +): Promise<AmountJson> { + if (refundPermissions.length === 0) { + throw Error("no refunds given"); + } + const coin0 = await oneShotGet( + ws.db, + Stores.coins, + refundPermissions[0].coin_pub, + ); + if (!coin0) { + throw Error("coin not found"); + } + let feeAcc = Amounts.getZero( + Amounts.parseOrThrow(refundPermissions[0].refund_amount).currency, + ); + + const denoms = await oneShotIterIndex( + ws.db, + Stores.denominations.exchangeBaseUrlIndex, + coin0.exchangeBaseUrl, + ).toArray(); + + for (const rp of refundPermissions) { + const coin = await oneShotGet(ws.db, Stores.coins, rp.coin_pub); + if (!coin) { + throw Error("coin not found"); + } + const denom = await oneShotGet(ws.db, Stores.denominations, [ + coin0.exchangeBaseUrl, + coin.denomPub, + ]); + if (!denom) { + throw Error(`denom not found (${coin.denomPub})`); + } + // FIXME: this assumes that the refund already happened. + // When it hasn't, the refresh cost is inaccurate. To fix this, + // we need introduce a flag to tell if a coin was refunded or + // refreshed normally (and what about incremental refunds?) + const refundAmount = Amounts.parseOrThrow(rp.refund_amount); + const refundFee = Amounts.parseOrThrow(rp.refund_fee); + const refreshCost = getTotalRefreshCost( + denoms, + denom, + Amounts.sub(refundAmount, refundFee).amount, + ); + feeAcc = Amounts.add(feeAcc, refreshCost, refundFee).amount; + } + return feeAcc; +} + +async function submitRefunds( + ws: InternalWalletState, + contractTermsHash: string, +): Promise<void> { + const purchase = await oneShotGet(ws.db, Stores.purchases, contractTermsHash); + if (!purchase) { + console.error( + "not submitting refunds, contract terms not found:", + contractTermsHash, + ); + return; + } + const pendingKeys = Object.keys(purchase.refundsPending); + if (pendingKeys.length === 0) { + return; + } + for (const pk of pendingKeys) { + const perm = purchase.refundsPending[pk]; + const req: RefundRequest = { + coin_pub: perm.coin_pub, + h_contract_terms: purchase.contractTermsHash, + merchant_pub: purchase.contractTerms.merchant_pub, + merchant_sig: perm.merchant_sig, + refund_amount: perm.refund_amount, + refund_fee: perm.refund_fee, + rtransaction_id: perm.rtransaction_id, + }; + console.log("sending refund permission", perm); + // FIXME: not correct once we support multiple exchanges per payment + const exchangeUrl = purchase.payReq.coins[0].exchange_url; + const reqUrl = new URL("refund", exchangeUrl); + const resp = await ws.http.postJson(reqUrl.href, req); + if (resp.status !== 200) { + console.error("refund failed", resp); + continue; + } + + // Transactionally mark successful refunds as done + const transformPurchase = ( + t: PurchaseRecord | undefined, + ): PurchaseRecord | undefined => { + if (!t) { + console.warn("purchase not found, not updating refund"); + return; + } + if (t.refundsPending[pk]) { + t.refundsDone[pk] = t.refundsPending[pk]; + delete t.refundsPending[pk]; + } + return t; + }; + const transformCoin = ( + c: CoinRecord | undefined, + ): CoinRecord | undefined => { + if (!c) { + console.warn("coin not found, can't apply refund"); + return; + } + const refundAmount = Amounts.parseOrThrow(perm.refund_amount); + const refundFee = Amounts.parseOrThrow(perm.refund_fee); + c.status = CoinStatus.Dirty; + c.currentAmount = Amounts.add(c.currentAmount, refundAmount).amount; + c.currentAmount = Amounts.sub(c.currentAmount, refundFee).amount; + + return c; + }; + + await runWithWriteTransaction( + ws.db, + [Stores.purchases, Stores.coins], + async tx => { + await tx.mutate(Stores.purchases, contractTermsHash, transformPurchase); + await tx.mutate(Stores.coins, perm.coin_pub, transformCoin); + }, + ); + refresh(ws, perm.coin_pub); + } + + ws.badge.showNotification(); + ws.notifier.notify(); +} + +export async function acceptRefundResponse( + ws: InternalWalletState, + refundResponse: MerchantRefundResponse, +): Promise<string> { + const refundPermissions = refundResponse.refund_permissions; + + if (!refundPermissions.length) { + console.warn("got empty refund list"); + throw Error("empty refund"); + } + + /** + * Add refund to purchase if not already added. + */ + function f(t: PurchaseRecord | undefined): PurchaseRecord | undefined { + if (!t) { + console.error("purchase not found, not adding refunds"); + return; + } + + t.timestamp_refund = getTimestampNow(); + + for (const perm of refundPermissions) { + if ( + !t.refundsPending[perm.merchant_sig] && + !t.refundsDone[perm.merchant_sig] + ) { + t.refundsPending[perm.merchant_sig] = perm; + } + } + return t; + } + + const hc = refundResponse.h_contract_terms; + + // Add the refund permissions to the purchase within a DB transaction + await oneShotMutate(ws.db, Stores.purchases, hc, f); + ws.notifier.notify(); + + await submitRefunds(ws, hc); + + return hc; +} + +/** + * Accept a refund, return the contract hash for the contract + * that was involved in the refund. + */ +export async function applyRefund( + ws: InternalWalletState, + talerRefundUri: string, +): Promise<string> { + const parseResult = parseRefundUri(talerRefundUri); + + if (!parseResult) { + throw Error("invalid refund URI"); + } + + const refundUrl = parseResult.refundUrl; + + logger.trace("processing refund"); + let resp; + try { + resp = await ws.http.get(refundUrl); + } catch (e) { + console.error("error downloading refund permission", e); + throw e; + } + + const refundResponse = MerchantRefundResponse.checked(resp.responseJson); + return acceptRefundResponse(ws, refundResponse); +} diff --git a/src/wallet-impl/reserves.ts b/src/wallet-impl/reserves.ts new file mode 100644 index 000000000..265eddce4 --- /dev/null +++ b/src/wallet-impl/reserves.ts @@ -0,0 +1,567 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + CreateReserveRequest, + CreateReserveResponse, + getTimestampNow, + ConfirmReserveRequest, + OperationError, +} from "../walletTypes"; +import { canonicalizeBaseUrl } from "../util/helpers"; +import { InternalWalletState } from "./state"; +import { + ReserveRecordStatus, + ReserveRecord, + CurrencyRecord, + Stores, + WithdrawalSessionRecord, +} from "../dbTypes"; +import { + oneShotMutate, + oneShotPut, + oneShotGet, + runWithWriteTransaction, + TransactionAbort, +} from "../util/query"; +import { Logger } from "../util/logging"; +import * as Amounts from "../util/amounts"; +import { updateExchangeFromUrl, getExchangeTrust } from "./exchanges"; +import { WithdrawOperationStatusResponse, ReserveStatus } from "../talerTypes"; +import { assertUnreachable } from "../util/assertUnreachable"; +import { OperationFailedAndReportedError } from "../wallet"; +import { encodeCrock } from "../crypto/talerCrypto"; +import { randomBytes } from "../crypto/primitives/nacl-fast"; +import { + getVerifiedWithdrawDenomList, + processWithdrawSession, +} from "./withdraw"; + +const logger = new Logger("reserves.ts"); + +/** + * Create a reserve, but do not flag it as confirmed yet. + * + * Adds the corresponding exchange as a trusted exchange if it is neither + * audited nor trusted already. + */ +export async function createReserve( + ws: InternalWalletState, + req: CreateReserveRequest, +): Promise<CreateReserveResponse> { + const keypair = await ws.cryptoApi.createEddsaKeypair(); + const now = getTimestampNow(); + const canonExchange = canonicalizeBaseUrl(req.exchange); + + let reserveStatus; + if (req.bankWithdrawStatusUrl) { + reserveStatus = ReserveRecordStatus.REGISTERING_BANK; + } else { + reserveStatus = ReserveRecordStatus.UNCONFIRMED; + } + + const currency = req.amount.currency; + + const reserveRecord: ReserveRecord = { + created: now, + withdrawAllocatedAmount: Amounts.getZero(currency), + withdrawCompletedAmount: Amounts.getZero(currency), + withdrawRemainingAmount: Amounts.getZero(currency), + exchangeBaseUrl: canonExchange, + hasPayback: false, + initiallyRequestedAmount: req.amount, + reservePriv: keypair.priv, + reservePub: keypair.pub, + senderWire: req.senderWire, + timestampConfirmed: undefined, + timestampReserveInfoPosted: undefined, + bankWithdrawStatusUrl: req.bankWithdrawStatusUrl, + exchangeWire: req.exchangeWire, + reserveStatus, + lastStatusQuery: undefined, + }; + + const senderWire = req.senderWire; + if (senderWire) { + const rec = { + paytoUri: senderWire, + }; + await oneShotPut(ws.db, Stores.senderWires, rec); + } + + const exchangeInfo = await updateExchangeFromUrl(ws, req.exchange); + const exchangeDetails = exchangeInfo.details; + if (!exchangeDetails) { + throw Error("exchange not updated"); + } + const { isAudited, isTrusted } = await getExchangeTrust(ws, exchangeInfo); + let currencyRecord = await oneShotGet( + ws.db, + Stores.currencies, + exchangeDetails.currency, + ); + if (!currencyRecord) { + currencyRecord = { + auditors: [], + exchanges: [], + fractionalDigits: 2, + name: exchangeDetails.currency, + }; + } + + if (!isAudited && !isTrusted) { + currencyRecord.exchanges.push({ + baseUrl: req.exchange, + exchangePub: exchangeDetails.masterPublicKey, + }); + } + + const cr: CurrencyRecord = currencyRecord; + + const resp = await runWithWriteTransaction( + ws.db, + [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; + }, + ); + + // Asynchronously process the reserve, but return + // to the caller already. + processReserve(ws, resp.reservePub).catch(e => { + console.error("Processing reserve failed:", e); + }); + + return resp; +} + +/** + * 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. + */ +export async function processReserve( + ws: InternalWalletState, + reservePub: string, +): Promise<void> { + const p = ws.memoProcessReserve.find(reservePub); + if (p) { + return p; + } else { + return ws.memoProcessReserve.put( + reservePub, + processReserveImpl(ws, reservePub), + ); + } +} + +async function registerReserveWithBank( + ws: InternalWalletState, + reservePub: string, +): Promise<void> { + let reserve = await oneShotGet(ws.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 ws.http.postJson(bankStatusUrl, { + reserve_pub: reservePub, + selected_exchange: reserve.exchangeWire, + }); + console.log("got response", bankResp); + await oneShotMutate(ws.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 processReserveBankStatus(ws, reservePub); +} + +export async function processReserveBankStatus( + ws: InternalWalletState, + reservePub: string, +): Promise<void> { + let reserve = await oneShotGet(ws.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; + } + + let status: WithdrawOperationStatusResponse; + try { + const statusResp = await ws.http.get(bankStatusUrl); + status = WithdrawOperationStatusResponse.checked(statusResp.responseJson); + } catch (e) { + throw e; + } + + if (status.selection_done) { + if (reserve.reserveStatus === ReserveRecordStatus.REGISTERING_BANK) { + await registerReserveWithBank(ws, reservePub); + return await processReserveBankStatus(ws, reservePub); + } + } else { + await registerReserveWithBank(ws, reservePub); + return await processReserveBankStatus(ws, reservePub); + } + + if (status.transfer_done) { + await oneShotMutate(ws.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; + }); + await processReserveImpl(ws, reservePub); + } else { + await oneShotMutate(ws.db, Stores.reserves, reservePub, r => { + switch (r.reserveStatus) { + case ReserveRecordStatus.WAIT_CONFIRM_BANK: + break; + default: + return; + } + r.bankWithdrawConfirmUrl = status.confirm_transfer_url; + return r; + }); + } +} + +async function setReserveError( + ws: InternalWalletState, + reservePub: string, + err: OperationError, +): Promise<void> { + const mut = (reserve: ReserveRecord) => { + reserve.lastError = err; + return reserve; + }; + await oneShotMutate(ws.db, Stores.reserves, reservePub, mut); +} + +/** + * Update the information about a reserve that is stored in the wallet + * by quering the reserve's exchange. + */ +async function updateReserve( + ws: InternalWalletState, + reservePub: string, +): Promise<void> { + const reserve = await oneShotGet(ws.db, Stores.reserves, reservePub); + if (!reserve) { + throw Error("reserve not in db"); + } + + if (reserve.timestampConfirmed === undefined) { + throw Error("reserve not confirmed yet"); + } + + if (reserve.reserveStatus !== ReserveRecordStatus.QUERYING_STATUS) { + return; + } + + const reqUrl = new URL("reserve/status", reserve.exchangeBaseUrl); + reqUrl.searchParams.set("reserve_pub", reservePub); + let resp; + try { + resp = await ws.http.get(reqUrl.href); + } catch (e) { + if (e.response?.status === 404) { + return; + } else { + const m = e.message; + setReserveError(ws, reservePub, { + type: "network", + details: {}, + message: m, + }); + throw new OperationFailedAndReportedError(m); + } + } + const reserveInfo = ReserveStatus.checked(resp.responseJson); + const balance = Amounts.parseOrThrow(reserveInfo.balance); + await oneShotMutate(ws.db, Stores.reserves, reserve.reservePub, r => { + if (r.reserveStatus !== ReserveRecordStatus.QUERYING_STATUS) { + return; + } + + // 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; + }); + ws.notifier.notify(); +} + +async function processReserveImpl( + ws: InternalWalletState, + reservePub: string, +): Promise<void> { + const reserve = await oneShotGet(ws.db, Stores.reserves, reservePub); + if (!reserve) { + console.log("not processing reserve: reserve does not exist"); + return; + } + logger.trace( + `Processing reserve ${reservePub} with status ${reserve.reserveStatus}`, + ); + switch (reserve.reserveStatus) { + case ReserveRecordStatus.UNCONFIRMED: + // nothing to do + break; + case ReserveRecordStatus.REGISTERING_BANK: + await processReserveBankStatus(ws, reservePub); + return processReserveImpl(ws, reservePub); + case ReserveRecordStatus.QUERYING_STATUS: + await updateReserve(ws, reservePub); + return processReserveImpl(ws, reservePub); + case ReserveRecordStatus.WITHDRAWING: + await depleteReserve(ws, reservePub); + break; + case ReserveRecordStatus.DORMANT: + // nothing to do + break; + case ReserveRecordStatus.WAIT_CONFIRM_BANK: + await processReserveBankStatus(ws, reservePub); + break; + default: + console.warn("unknown reserve record status:", reserve.reserveStatus); + assertUnreachable(reserve.reserveStatus); + break; + } +} + +export async function confirmReserve( + ws: InternalWalletState, + req: ConfirmReserveRequest, +): Promise<void> { + const now = getTimestampNow(); + await oneShotMutate(ws.db, Stores.reserves, req.reservePub, reserve => { + if (reserve.reserveStatus !== ReserveRecordStatus.UNCONFIRMED) { + return; + } + reserve.timestampConfirmed = now; + reserve.reserveStatus = ReserveRecordStatus.QUERYING_STATUS; + return reserve; + }); + + ws.notifier.notify(); + + processReserve(ws, req.reservePub).catch(e => { + console.log("processing reserve failed:", e); + }); +} + +/** + * Withdraw coins from a reserve until it is empty. + * + * When finished, marks the reserve as depleted by setting + * the depleted timestamp. + */ +async function depleteReserve( + ws: InternalWalletState, + reservePub: string, +): Promise<void> { + const reserve = await oneShotGet(ws.db, Stores.reserves, reservePub); + if (!reserve) { + return; + } + if (reserve.reserveStatus !== ReserveRecordStatus.WITHDRAWING) { + return; + } + logger.trace(`depleting reserve ${reservePub}`); + + const withdrawAmount = reserve.withdrawRemainingAmount; + + logger.trace(`getting denom list`); + + const denomsForWithdraw = await getVerifiedWithdrawDenomList( + ws, + 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 setReserveError(ws, reserve.reservePub, { + type: "internal", + message: m, + details: {}, + }); + console.log(m); + throw new OperationFailedAndReportedError(m); + } + + logger.trace("selected denominations"); + + const withdrawalSessionId = encodeCrock(randomBytes(32)); + + const withdrawalRecord: WithdrawalSessionRecord = { + withdrawSessionId: withdrawalSessionId, + exchangeBaseUrl: reserve.exchangeBaseUrl, + source: { + type: "reserve", + reservePub: reserve.reservePub, + }, + withdrawalAmount: Amounts.toString(withdrawAmount), + startTimestamp: getTimestampNow(), + denoms: denomsForWithdraw.map(x => x.denomPub), + withdrawn: denomsForWithdraw.map(x => false), + planchets: denomsForWithdraw.map(x => undefined), + }; + + const totalCoinValue = Amounts.sum(denomsForWithdraw.map(x => x.value)) + .amount; + const totalCoinWithdrawFee = Amounts.sum( + denomsForWithdraw.map(x => x.feeWithdraw), + ).amount; + const totalWithdrawAmount = Amounts.add(totalCoinValue, totalCoinWithdrawFee) + .amount; + + function mutateReserve(r: ReserveRecord): ReserveRecord { + const remaining = Amounts.sub( + r.withdrawRemainingAmount, + totalWithdrawAmount, + ); + if (remaining.saturated) { + console.error("can't create planchets, saturated"); + throw TransactionAbort; + } + 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; + } + + const success = await runWithWriteTransaction( + ws.db, + [Stores.withdrawalSession, Stores.reserves], + async tx => { + const myReserve = await tx.get(Stores.reserves, reservePub); + if (!myReserve) { + return false; + } + if (myReserve.reserveStatus !== ReserveRecordStatus.WITHDRAWING) { + return false; + } + await tx.mutate(Stores.reserves, reserve.reservePub, mutateReserve); + await tx.put(Stores.withdrawalSession, withdrawalRecord); + return true; + }, + ); + + if (success) { + console.log("processing new withdraw session"); + await processWithdrawSession(ws, withdrawalSessionId); + } else { + console.trace("withdraw session already existed"); + } +} diff --git a/src/wallet-impl/return.ts b/src/wallet-impl/return.ts new file mode 100644 index 000000000..9cf12052d --- /dev/null +++ b/src/wallet-impl/return.ts @@ -0,0 +1,274 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Imports. + */ +import { + HistoryQuery, + HistoryEvent, + WalletBalance, + WalletBalanceEntry, + ReturnCoinsRequest, + CoinWithDenom, +} from "../walletTypes"; +import { oneShotIter, runWithWriteTransaction, oneShotGet, oneShotIterIndex, oneShotPut } from "../util/query"; +import { InternalWalletState } from "./state"; +import { Stores, TipRecord, CoinStatus, CoinsReturnRecord, CoinRecord } from "../dbTypes"; +import * as Amounts from "../util/amounts"; +import { AmountJson } from "../util/amounts"; +import { Logger } from "../util/logging"; +import { canonicalJson } from "../util/helpers"; +import { ContractTerms } from "../talerTypes"; +import { selectPayCoins } from "./pay"; + +const logger = new Logger("return.ts"); + +async function getCoinsForReturn( + ws: InternalWalletState, + exchangeBaseUrl: string, + amount: AmountJson, +): Promise<CoinWithDenom[] | undefined> { + const exchange = await oneShotGet( + ws.db, + Stores.exchanges, + exchangeBaseUrl, + ); + if (!exchange) { + throw Error(`Exchange ${exchangeBaseUrl} not known to the wallet`); + } + + const coins: CoinRecord[] = await oneShotIterIndex( + ws.db, + Stores.coins.exchangeBaseUrlIndex, + exchange.baseUrl, + ).toArray(); + + if (!coins || !coins.length) { + return []; + } + + const denoms = await oneShotIterIndex( + ws.db, + Stores.denominations.exchangeBaseUrlIndex, + exchange.baseUrl, + ).toArray(); + + // Denomination of the first coin, we assume that all other + // coins have the same currency + const firstDenom = await oneShotGet(ws.db, Stores.denominations, [ + exchange.baseUrl, + coins[0].denomPub, + ]); + if (!firstDenom) { + throw Error("db inconsistent"); + } + const currency = firstDenom.value.currency; + + const cds: CoinWithDenom[] = []; + for (const coin of coins) { + const denom = await oneShotGet(ws.db, Stores.denominations, [ + exchange.baseUrl, + coin.denomPub, + ]); + if (!denom) { + throw Error("db inconsistent"); + } + if (denom.value.currency !== currency) { + console.warn( + `same pubkey for different currencies at exchange ${exchange.baseUrl}`, + ); + continue; + } + if (coin.suspended) { + continue; + } + if (coin.status !== CoinStatus.Fresh) { + continue; + } + cds.push({ coin, denom }); + } + + const res = selectPayCoins(denoms, cds, amount, amount); + if (res) { + return res.cds; + } + return undefined; +} + + +/** + * Trigger paying coins back into the user's account. + */ +export async function returnCoins( + ws: InternalWalletState, + req: ReturnCoinsRequest, +): Promise<void> { + logger.trace("got returnCoins request", req); + const wireType = (req.senderWire as any).type; + logger.trace("wireType", wireType); + if (!wireType || typeof wireType !== "string") { + console.error(`wire type must be a non-empty string, not ${wireType}`); + return; + } + const stampSecNow = Math.floor(new Date().getTime() / 1000); + const exchange = await oneShotGet(ws.db, Stores.exchanges, req.exchange); + if (!exchange) { + console.error(`Exchange ${req.exchange} not known to the wallet`); + return; + } + const exchangeDetails = exchange.details; + if (!exchangeDetails) { + throw Error("exchange information needs to be updated first."); + } + logger.trace("selecting coins for return:", req); + const cds = await getCoinsForReturn(ws, req.exchange, req.amount); + logger.trace(cds); + + if (!cds) { + throw Error("coin return impossible, can't select coins"); + } + + const { priv, pub } = await ws.cryptoApi.createEddsaKeypair(); + + const wireHash = await ws.cryptoApi.hashString( + canonicalJson(req.senderWire), + ); + + const contractTerms: ContractTerms = { + H_wire: wireHash, + amount: Amounts.toString(req.amount), + auditors: [], + exchanges: [ + { master_pub: exchangeDetails.masterPublicKey, url: exchange.baseUrl }, + ], + extra: {}, + fulfillment_url: "", + locations: [], + max_fee: Amounts.toString(req.amount), + merchant: {}, + merchant_pub: pub, + order_id: "none", + pay_deadline: `/Date(${stampSecNow + 30 * 5})/`, + wire_transfer_deadline: `/Date(${stampSecNow + 60 * 5})/`, + merchant_base_url: "taler://return-to-account", + products: [], + refund_deadline: `/Date(${stampSecNow + 60 * 5})/`, + timestamp: `/Date(${stampSecNow})/`, + wire_method: wireType, + }; + + const contractTermsHash = await ws.cryptoApi.hashString( + canonicalJson(contractTerms), + ); + + const payCoinInfo = await ws.cryptoApi.signDeposit( + contractTerms, + cds, + Amounts.parseOrThrow(contractTerms.amount), + ); + + logger.trace("pci", payCoinInfo); + + const coins = payCoinInfo.sigs.map(s => ({ coinPaySig: s })); + + const coinsReturnRecord: CoinsReturnRecord = { + coins, + contractTerms, + contractTermsHash, + exchange: exchange.baseUrl, + merchantPriv: priv, + wire: req.senderWire, + }; + + await runWithWriteTransaction( + ws.db, + [Stores.coinsReturns, Stores.coins], + async tx => { + await tx.put(Stores.coinsReturns, coinsReturnRecord); + for (let c of payCoinInfo.updatedCoins) { + await tx.put(Stores.coins, c); + } + }, + ); + ws.badge.showNotification(); + ws.notifier.notify(); + + depositReturnedCoins(ws, coinsReturnRecord); +} + +async function depositReturnedCoins( + ws: InternalWalletState, + coinsReturnRecord: CoinsReturnRecord, +): Promise<void> { + for (const c of coinsReturnRecord.coins) { + if (c.depositedSig) { + continue; + } + const req = { + H_wire: coinsReturnRecord.contractTerms.H_wire, + coin_pub: c.coinPaySig.coin_pub, + coin_sig: c.coinPaySig.coin_sig, + contribution: c.coinPaySig.contribution, + denom_pub: c.coinPaySig.denom_pub, + h_contract_terms: coinsReturnRecord.contractTermsHash, + merchant_pub: coinsReturnRecord.contractTerms.merchant_pub, + pay_deadline: coinsReturnRecord.contractTerms.pay_deadline, + refund_deadline: coinsReturnRecord.contractTerms.refund_deadline, + timestamp: coinsReturnRecord.contractTerms.timestamp, + ub_sig: c.coinPaySig.ub_sig, + wire: coinsReturnRecord.wire, + wire_transfer_deadline: coinsReturnRecord.contractTerms.pay_deadline, + }; + logger.trace("req", req); + const reqUrl = new URL("deposit", coinsReturnRecord.exchange); + const resp = await ws.http.postJson(reqUrl.href, req); + if (resp.status !== 200) { + console.error("deposit failed due to status code", resp); + continue; + } + const respJson = resp.responseJson; + if (respJson.status !== "DEPOSIT_OK") { + console.error("deposit failed", resp); + continue; + } + + if (!respJson.sig) { + console.error("invalid 'sig' field", resp); + continue; + } + + // FIXME: verify signature + + // For every successful deposit, we replace the old record with an updated one + const currentCrr = await oneShotGet( + ws.db, + Stores.coinsReturns, + coinsReturnRecord.contractTermsHash, + ); + if (!currentCrr) { + console.error("database inconsistent"); + continue; + } + for (const nc of currentCrr.coins) { + if (nc.coinPaySig.coin_pub === c.coinPaySig.coin_pub) { + nc.depositedSig = respJson.sig; + } + } + await oneShotPut(ws.db, Stores.coinsReturns, currentCrr); + ws.notifier.notify(); + } +} diff --git a/src/wallet-impl/state.ts b/src/wallet-impl/state.ts new file mode 100644 index 000000000..3d6bb8bdf --- /dev/null +++ b/src/wallet-impl/state.ts @@ -0,0 +1,32 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { HttpRequestLibrary } from "../util/http"; +import { Badge, Notifier, NextUrlResult } from "../walletTypes"; +import { SpeculativePayData } from "./pay"; +import { CryptoApi } from "../crypto/cryptoApi"; +import { AsyncOpMemo } from "../util/asyncMemo"; + +export interface InternalWalletState { + db: IDBDatabase; + http: HttpRequestLibrary; + badge: Badge; + notifier: Notifier; + cryptoApi: CryptoApi; + speculativePayData: SpeculativePayData | undefined; + cachedNextUrl: { [fulfillmentUrl: string]: NextUrlResult }; + memoProcessReserve: AsyncOpMemo<void>; +}
\ No newline at end of file diff --git a/src/wallet-impl/tip.ts b/src/wallet-impl/tip.ts new file mode 100644 index 000000000..b102d026f --- /dev/null +++ b/src/wallet-impl/tip.ts @@ -0,0 +1,246 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + + +import { oneShotGet, oneShotPut, oneShotMutate, runWithWriteTransaction } from "../util/query"; +import { InternalWalletState } from "./state"; +import { parseTipUri } from "../util/taleruri"; +import { TipStatus, getTimestampNow } from "../walletTypes"; +import { TipPickupGetResponse, TipPlanchetDetail, TipResponse } from "../talerTypes"; +import * as Amounts from "../util/amounts"; +import { Stores, PlanchetRecord, WithdrawalSessionRecord } from "../dbTypes"; +import { getWithdrawDetailsForAmount, getVerifiedWithdrawDenomList, processWithdrawSession } from "./withdraw"; +import { getTalerStampSec } from "../util/helpers"; +import { updateExchangeFromUrl } from "./exchanges"; +import { getRandomBytes, encodeCrock } from "../crypto/talerCrypto"; + + +export async function getTipStatus( + ws: InternalWalletState, + talerTipUri: string): Promise<TipStatus> { + const res = parseTipUri(talerTipUri); + if (!res) { + throw Error("invalid taler://tip URI"); + } + + const tipStatusUrl = new URL("tip-pickup", res.merchantBaseUrl); + tipStatusUrl.searchParams.set("tip_id", res.merchantTipId); + console.log("checking tip status from", tipStatusUrl.href); + const merchantResp = await ws.http.get(tipStatusUrl.href); + console.log("resp:", merchantResp.responseJson); + const tipPickupStatus = TipPickupGetResponse.checked( + merchantResp.responseJson, + ); + + console.log("status", tipPickupStatus); + + let amount = Amounts.parseOrThrow(tipPickupStatus.amount); + + let tipRecord = await oneShotGet(ws.db, Stores.tips, [ + res.merchantTipId, + res.merchantOrigin, + ]); + + if (!tipRecord) { + const withdrawDetails = await getWithdrawDetailsForAmount( + ws, + tipPickupStatus.exchange_url, + amount, + ); + + const tipId = encodeCrock(getRandomBytes(32)); + + tipRecord = { + tipId, + accepted: false, + amount, + deadline: getTalerStampSec(tipPickupStatus.stamp_expire)!, + exchangeUrl: tipPickupStatus.exchange_url, + merchantBaseUrl: res.merchantBaseUrl, + nextUrl: undefined, + pickedUp: false, + planchets: undefined, + response: undefined, + timestamp: getTimestampNow(), + merchantTipId: res.merchantTipId, + totalFees: Amounts.add( + withdrawDetails.overhead, + withdrawDetails.withdrawFee, + ).amount, + }; + await oneShotPut(ws.db, Stores.tips, tipRecord); + } + + const tipStatus: TipStatus = { + accepted: !!tipRecord && tipRecord.accepted, + amount: Amounts.parseOrThrow(tipPickupStatus.amount), + amountLeft: Amounts.parseOrThrow(tipPickupStatus.amount_left), + exchangeUrl: tipPickupStatus.exchange_url, + nextUrl: tipPickupStatus.extra.next_url, + merchantOrigin: res.merchantOrigin, + merchantTipId: res.merchantTipId, + expirationTimestamp: getTalerStampSec(tipPickupStatus.stamp_expire)!, + timestamp: getTalerStampSec(tipPickupStatus.stamp_created)!, + totalFees: tipRecord.totalFees, + tipId: tipRecord.tipId, + }; + + return tipStatus; +} + +export async function processTip( + ws: InternalWalletState, + tipId: string, +) { + let tipRecord = await oneShotGet(ws.db, Stores.tips, tipId); + if (!tipRecord) { + return; + } + + if (tipRecord.pickedUp) { + console.log("tip already picked up"); + return; + } + + if (!tipRecord.planchets) { + await updateExchangeFromUrl(ws, tipRecord.exchangeUrl); + const denomsForWithdraw = await getVerifiedWithdrawDenomList( + ws, + tipRecord.exchangeUrl, + tipRecord.amount, + ); + + const planchets = await Promise.all( + denomsForWithdraw.map(d => ws.cryptoApi.createTipPlanchet(d)), + ); + + await oneShotMutate(ws.db, Stores.tips, tipId, r => { + if (!r.planchets) { + r.planchets = planchets; + } + return r; + }); + } + + tipRecord = await oneShotGet(ws.db, Stores.tips, tipId); + if (!tipRecord) { + throw Error("tip not in database"); + } + + if (!tipRecord.planchets) { + throw Error("invariant violated"); + } + + console.log("got planchets for tip!"); + + // Planchets in the form that the merchant expects + const planchetsDetail: TipPlanchetDetail[] = tipRecord.planchets.map(p => ({ + coin_ev: p.coinEv, + denom_pub_hash: p.denomPubHash, + })); + + let merchantResp; + + const tipStatusUrl = new URL("tip-pickup", tipRecord.merchantBaseUrl); + + try { + const req = { planchets: planchetsDetail, tip_id: tipRecord.merchantTipId }; + merchantResp = await ws.http.postJson(tipStatusUrl.href, req); + console.log("got merchant resp:", merchantResp); + } catch (e) { + console.log("tipping failed", e); + throw e; + } + + const response = TipResponse.checked(merchantResp.responseJson); + + if (response.reserve_sigs.length !== tipRecord.planchets.length) { + throw Error("number of tip responses does not match requested planchets"); + } + + const planchets: PlanchetRecord[] = []; + + for (let i = 0; i < tipRecord.planchets.length; i++) { + 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, + reservePub: response.reserve_pub, + withdrawSig: response.reserve_sigs[i].reserve_sig, + isFromTip: true, + }; + planchets.push(planchet); + } + + const withdrawalSessionId = encodeCrock(getRandomBytes(32)); + + const withdrawalSession: WithdrawalSessionRecord = { + denoms: planchets.map((x) => x.denomPub), + exchangeBaseUrl: tipRecord.exchangeUrl, + planchets: planchets, + source: { + type: "tip", + tipId: tipRecord.tipId, + }, + startTimestamp: getTimestampNow(), + withdrawSessionId: withdrawalSessionId, + withdrawalAmount: Amounts.toString(tipRecord.amount), + withdrawn: planchets.map((x) => false), + }; + + + await runWithWriteTransaction(ws.db, [Stores.tips, Stores.withdrawalSession], async (tx) => { + const tr = await tx.get(Stores.tips, tipId); + if (!tr) { + return; + } + if (tr.pickedUp) { + return; + } + tr.pickedUp = true; + + await tx.put(Stores.tips, tr); + await tx.put(Stores.withdrawalSession, withdrawalSession); + }); + + await processWithdrawSession(ws, withdrawalSessionId); + + ws.notifier.notify(); + ws.badge.showNotification(); + return; +} + +export async function acceptTip( + ws: InternalWalletState, + tipId: string, +): Promise<void> { + const tipRecord = await oneShotGet(ws.db, Stores.tips, tipId); + if (!tipRecord) { + console.log("tip not found"); + return; + } + + tipRecord.accepted = true; + await oneShotPut(ws.db, Stores.tips, tipRecord); + + await processTip(ws, tipId); + return; +} diff --git a/src/wallet-impl/withdraw.ts b/src/wallet-impl/withdraw.ts new file mode 100644 index 000000000..4e2d80556 --- /dev/null +++ b/src/wallet-impl/withdraw.ts @@ -0,0 +1,577 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { AmountJson } from "../util/amounts"; +import { + DenominationRecord, + Stores, + DenominationStatus, + CoinStatus, + CoinRecord, + PlanchetRecord, +} from "../dbTypes"; +import * as Amounts from "../util/amounts"; +import { + getTimestampNow, + AcceptWithdrawalResponse, + DownloadedWithdrawInfo, + ReserveCreationInfo, + WithdrawDetails, +} from "../walletTypes"; +import { WithdrawOperationStatusResponse } from "../talerTypes"; +import { InternalWalletState } from "./state"; +import { parseWithdrawUri } from "../util/taleruri"; +import { Logger } from "../util/logging"; +import { + oneShotGet, + oneShotPut, + oneShotIterIndex, + oneShotGetIndexed, + runWithWriteTransaction, +} from "../util/query"; +import { + updateExchangeFromUrl, + getExchangePaytoUri, + getExchangeTrust, +} from "./exchanges"; +import { createReserve, processReserveBankStatus } from "./reserves"; +import { WALLET_PROTOCOL_VERSION } from "../wallet"; + +import * as LibtoolVersion from "../util/libtoolVersion"; + +const logger = new Logger("withdraw.ts"); + +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; + return started && stillOkay; +} + +/** + * Get a list of denominations (with repetitions possible) + * whose total value is as close as possible to the available + * amount, but never larger. + */ +export function getWithdrawDenomList( + amountAvailable: AmountJson, + denoms: DenominationRecord[], +): DenominationRecord[] { + let remaining = Amounts.copy(amountAvailable); + const ds: DenominationRecord[] = []; + + denoms = denoms.filter(isWithdrawableDenom); + denoms.sort((d1, d2) => Amounts.cmp(d2.value, d1.value)); + + // This is an arbitrary number of coins + // we can withdraw in one go. It's not clear if this limit + // is useful ... + for (let i = 0; i < 1000; i++) { + let found = false; + for (const d of denoms) { + const cost = Amounts.add(d.value, d.feeWithdraw).amount; + if (Amounts.cmp(remaining, cost) < 0) { + continue; + } + found = true; + remaining = Amounts.sub(remaining, cost).amount; + ds.push(d); + break; + } + if (!found) { + break; + } + } + return ds; +} + +/** + * Get information about a withdrawal from + * a taler://withdraw URI. + */ +export async function getWithdrawalInfo( + ws: InternalWalletState, + talerWithdrawUri: string, +): Promise<DownloadedWithdrawInfo> { + const uriResult = parseWithdrawUri(talerWithdrawUri); + if (!uriResult) { + throw Error("can't parse URL"); + } + const resp = await ws.http.get(uriResult.statusUrl); + console.log("resp:", resp.responseJson); + const status = WithdrawOperationStatusResponse.checked(resp.responseJson); + return { + amount: Amounts.parseOrThrow(status.amount), + confirmTransferUrl: status.confirm_transfer_url, + extractedStatusUrl: uriResult.statusUrl, + selectionDone: status.selection_done, + senderWire: status.sender_wire, + suggestedExchange: status.suggested_exchange, + transferDone: status.transfer_done, + wireTypes: status.wire_types, + }; +} + +export async function acceptWithdrawal( + ws: InternalWalletState, + talerWithdrawUri: string, + selectedExchange: string, +): Promise<AcceptWithdrawalResponse> { + const withdrawInfo = await getWithdrawalInfo(ws, talerWithdrawUri); + const exchangeWire = await getExchangePaytoUri( + ws, + selectedExchange, + withdrawInfo.wireTypes, + ); + const reserve = await createReserve(ws, { + amount: withdrawInfo.amount, + bankWithdrawStatusUrl: withdrawInfo.extractedStatusUrl, + exchange: selectedExchange, + 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 processReserveBankStatus(ws, reserve.reservePub); + console.log("acceptWithdrawal: returning"); + return { + reservePub: reserve.reservePub, + confirmTransferUrl: withdrawInfo.confirmTransferUrl, + }; +} + +async function getPossibleDenoms( + ws: InternalWalletState, + exchangeBaseUrl: string, +): Promise<DenominationRecord[]> { + return await oneShotIterIndex( + ws.db, + Stores.denominations.exchangeBaseUrlIndex, + exchangeBaseUrl, + ).filter(d => { + return ( + d.status === DenominationStatus.Unverified || + d.status === DenominationStatus.VerifiedGood + ); + }); +} + +/** + * Given a planchet, withdraw a coin from the exchange. + */ +async function processPlanchet( + ws: InternalWalletState, + withdrawalSessionId: string, + coinIdx: number, +): Promise<void> { + const withdrawalSession = await oneShotGet( + ws.db, + Stores.withdrawalSession, + withdrawalSessionId, + ); + if (!withdrawalSession) { + return; + } + if (withdrawalSession.withdrawn[coinIdx]) { + return; + } + if (withdrawalSession.source.type === "reserve") { + + } + const planchet = withdrawalSession.planchets[coinIdx]; + if (!planchet) { + console.log("processPlanchet: planchet not found"); + return; + } + const exchange = await oneShotGet( + ws.db, + Stores.exchanges, + withdrawalSession.exchangeBaseUrl, + ); + if (!exchange) { + console.error("db inconsistent: exchange for planchet not found"); + return; + } + + const denom = await oneShotGet(ws.db, Stores.denominations, [ + withdrawalSession.exchangeBaseUrl, + planchet.denomPub, + ]); + + if (!denom) { + console.error("db inconsistent: denom for planchet not found"); + return; + } + + const wd: any = {}; + wd.denom_pub_hash = planchet.denomPubHash; + wd.reserve_pub = planchet.reservePub; + wd.reserve_sig = planchet.withdrawSig; + wd.coin_ev = planchet.coinEv; + const reqUrl = new URL("reserve/withdraw", exchange.baseUrl).href; + const resp = await ws.http.postJson(reqUrl, wd); + + const r = resp.responseJson; + + const denomSig = await ws.cryptoApi.rsaUnblind( + r.ev_sig, + planchet.blindingKey, + planchet.denomPub, + ); + + const coin: CoinRecord = { + blindingKey: planchet.blindingKey, + coinPriv: planchet.coinPriv, + coinPub: planchet.coinPub, + currentAmount: planchet.coinValue, + denomPub: planchet.denomPub, + denomPubHash: planchet.denomPubHash, + denomSig, + exchangeBaseUrl: withdrawalSession.exchangeBaseUrl, + reservePub: planchet.reservePub, + status: CoinStatus.Fresh, + coinIndex: coinIdx, + withdrawSessionId: withdrawalSessionId, + }; + + await runWithWriteTransaction( + ws.db, + [Stores.coins, Stores.withdrawalSession, Stores.reserves], + async tx => { + const ws = await tx.get( + Stores.withdrawalSession, + withdrawalSessionId, + ); + if (!ws) { + return; + } + if (ws.withdrawn[coinIdx]) { + // Already withdrawn + return; + } + ws.withdrawn[coinIdx] = true; + await tx.put(Stores.withdrawalSession, ws); + if (!planchet.isFromTip) { + const r = await tx.get(Stores.reserves, planchet.reservePub); + if (r) { + r.withdrawCompletedAmount = Amounts.add( + r.withdrawCompletedAmount, + Amounts.add(denom.value, denom.feeWithdraw).amount, + ).amount; + await tx.put(Stores.reserves, r); + } + } + await tx.add(Stores.coins, coin); + }, + ); + ws.notifier.notify(); + logger.trace(`withdraw of one coin ${coin.coinPub} finished`); +} + +/** + * Get a list of denominations to withdraw from the given exchange for the + * given amount, making sure that all denominations' signatures are verified. + * + * Writes to the DB in order to record the result from verifying + * denominations. + */ +export async function getVerifiedWithdrawDenomList( + ws: InternalWalletState, + exchangeBaseUrl: string, + amount: AmountJson, +): Promise<DenominationRecord[]> { + const exchange = await oneShotGet(ws.db, Stores.exchanges, 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 getPossibleDenoms(ws, exchange.baseUrl); + + console.log("got possible denoms"); + + let allValid = false; + + let selectedDenoms: DenominationRecord[]; + + do { + 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 ws.cryptoApi.isValidDenom( + denom, + exchangeDetails.masterPublicKey, + ); + console.log("done checking validity"); + if (!valid) { + denom.status = DenominationStatus.VerifiedBad; + allValid = false; + } else { + denom.status = DenominationStatus.VerifiedGood; + nextPossibleDenoms.push(denom); + } + await oneShotPut(ws.db, Stores.denominations, denom); + } else { + nextPossibleDenoms.push(denom); + } + } + } while (selectedDenoms.length > 0 && !allValid); + + console.log("returning denoms"); + + return selectedDenoms; +} + +async function processWithdrawCoin( + ws: InternalWalletState, + withdrawalSessionId: string, + coinIndex: number, +) { + logger.info("starting withdraw for coin"); + const withdrawalSession = await oneShotGet( + ws.db, + Stores.withdrawalSession, + withdrawalSessionId, + ); + if (!withdrawalSession) { + console.log("ws doesn't exist"); + return; + } + + const coin = await oneShotGetIndexed( + ws.db, + Stores.coins.byWithdrawalWithIdx, + [withdrawalSessionId, coinIndex], + ); + + if (coin) { + console.log("coin already exists"); + return; + } + + if (withdrawalSession.planchets[coinIndex]) { + return processPlanchet(ws, withdrawalSessionId, coinIndex); + } else { + const src = withdrawalSession.source; + if (src.type !== "reserve") { + throw Error("invalid state"); + } + const reserve = await oneShotGet(ws.db, Stores.reserves, src.reservePub) + if (!reserve) { + return; + } + const denom = await oneShotGet(ws.db, Stores.denominations, [ + withdrawalSession.exchangeBaseUrl, + withdrawalSession.denoms[coinIndex], + ]); + if (!denom) { + return; + } + const r = await ws.cryptoApi.createPlanchet({ + denomPub: denom.denomPub, + feeWithdraw: denom.feeWithdraw, + reservePriv: reserve.reservePriv, + reservePub: reserve.reservePub, + value: denom.value, + }); + const newPlanchet: PlanchetRecord = { + blindingKey: r.blindingKey, + coinEv: r.coinEv, + coinPriv: r.coinPriv, + coinPub: r.coinPub, + coinValue: r.coinValue, + denomPub: r.denomPub, + denomPubHash: r.denomPubHash, + isFromTip: false, + reservePub: r.reservePub, + withdrawSig: r.withdrawSig, + }; + await runWithWriteTransaction( + ws.db, + [Stores.withdrawalSession], + async tx => { + const myWs = await tx.get( + Stores.withdrawalSession, + withdrawalSessionId, + ); + if (!myWs) { + return; + } + if (myWs.planchets[coinIndex]) { + return; + } + myWs.planchets[coinIndex] = newPlanchet; + await tx.put(Stores.withdrawalSession, myWs); + }, + ); + await processPlanchet(ws, withdrawalSessionId, coinIndex); + } +} + +export async function processWithdrawSession( + ws: InternalWalletState, + withdrawalSessionId: string, +): Promise<void> { + logger.trace("processing withdraw session", withdrawalSessionId); + const withdrawalSession = await oneShotGet( + ws.db, + Stores.withdrawalSession, + withdrawalSessionId, + ); + if (!withdrawalSession) { + logger.trace("withdraw session doesn't exist"); + return; + } + + const ps = withdrawalSession.denoms.map((d, i) => + processWithdrawCoin(ws, withdrawalSessionId, i), + ); + await Promise.all(ps); + ws.badge.showNotification(); + return; +} + +export async function getWithdrawDetailsForAmount( + ws: InternalWalletState, + baseUrl: string, + amount: AmountJson, +): Promise<ReserveCreationInfo> { + const exchangeInfo = await updateExchangeFromUrl(ws, baseUrl); + const exchangeDetails = exchangeInfo.details; + if (!exchangeDetails) { + throw Error(`exchange ${exchangeInfo.baseUrl} details not available`); + } + const exchangeWireInfo = exchangeInfo.wireInfo; + if (!exchangeWireInfo) { + throw Error(`exchange ${exchangeInfo.baseUrl} wire details not available`); + } + + const selectedDenoms = await getVerifiedWithdrawDenomList( + ws, + baseUrl, + amount, + ); + let acc = Amounts.getZero(amount.currency); + for (const d of selectedDenoms) { + acc = Amounts.add(acc, d.feeWithdraw).amount; + } + const actualCoinCost = selectedDenoms + .map((d: DenominationRecord) => Amounts.add(d.value, d.feeWithdraw).amount) + .reduce((a, b) => Amounts.add(a, b).amount); + + const exchangeWireAccounts: string[] = []; + for (let account of exchangeWireInfo.accounts) { + exchangeWireAccounts.push(account.url); + } + + const { isTrusted, isAudited } = await getExchangeTrust(ws, exchangeInfo); + + let earliestDepositExpiration = selectedDenoms[0].stampExpireDeposit; + for (let i = 1; i < selectedDenoms.length; i++) { + const expireDeposit = selectedDenoms[i].stampExpireDeposit; + if (expireDeposit.t_ms < earliestDepositExpiration.t_ms) { + earliestDepositExpiration = expireDeposit; + } + } + + const possibleDenoms = await oneShotIterIndex( + ws.db, + Stores.denominations.exchangeBaseUrlIndex, + baseUrl, + ).filter(d => d.isOffered); + + const trustedAuditorPubs = []; + const currencyRecord = await oneShotGet( + ws.db, + Stores.currencies, + amount.currency, + ); + if (currencyRecord) { + trustedAuditorPubs.push(...currencyRecord.auditors.map(a => a.auditorPub)); + } + + let versionMatch; + if (exchangeDetails.protocolVersion) { + versionMatch = LibtoolVersion.compare( + WALLET_PROTOCOL_VERSION, + exchangeDetails.protocolVersion, + ); + + if ( + versionMatch && + !versionMatch.compatible && + versionMatch.currentCmp === -1 + ) { + console.warn( + `wallet version ${WALLET_PROTOCOL_VERSION} might be outdated ` + + `(exchange has ${exchangeDetails.protocolVersion}), checking for updates`, + ); + } + } + + const ret: ReserveCreationInfo = { + earliestDepositExpiration, + exchangeInfo, + exchangeWireAccounts, + exchangeVersion: exchangeDetails.protocolVersion || "unknown", + isAudited, + isTrusted, + numOfferedDenoms: possibleDenoms.length, + overhead: Amounts.sub(amount, actualCoinCost).amount, + selectedDenoms, + trustedAuditorPubs, + versionMatch, + walletVersion: WALLET_PROTOCOL_VERSION, + wireFees: exchangeWireInfo, + withdrawFee: acc, + }; + return ret; +} + +export async function getWithdrawDetailsForUri( + ws: InternalWalletState, + talerWithdrawUri: string, + maybeSelectedExchange?: string, +): Promise<WithdrawDetails> { + const info = await getWithdrawalInfo(ws, talerWithdrawUri); + let rci: ReserveCreationInfo | undefined = undefined; + if (maybeSelectedExchange) { + rci = await getWithdrawDetailsForAmount( + ws, + maybeSelectedExchange, + info.amount, + ); + } + return { + withdrawInfo: info, + reserveCreationInfo: rci, + }; +} diff --git a/src/wallet-test.ts b/src/wallet-test.ts index fef11ae5d..cc8532f07 100644 --- a/src/wallet-test.ts +++ b/src/wallet-test.ts @@ -14,7 +14,6 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - import test from "ava"; import * as dbTypes from "./dbTypes"; @@ -22,9 +21,9 @@ import * as types from "./walletTypes"; import * as wallet from "./wallet"; -import { AmountJson} from "./amounts"; -import * as Amounts from "./amounts"; - +import { AmountJson } from "./util/amounts"; +import * as Amounts from "./util/amounts"; +import { selectPayCoins } from "./wallet-impl/pay"; function a(x: string): AmountJson { const amt = Amounts.parse(x); @@ -34,8 +33,11 @@ function a(x: string): AmountJson { return amt; } - -function fakeCwd(current: string, value: string, feeDeposit: string): types.CoinWithDenom { +function fakeCwd( + current: string, + value: string, + feeDeposit: string, +): types.CoinWithDenom { return { coin: { blindingKey: "(mock)", @@ -71,14 +73,13 @@ function fakeCwd(current: string, value: string, feeDeposit: string): types.Coin }; } - -test("coin selection 1", (t) => { +test("coin selection 1", t => { const cds: types.CoinWithDenom[] = [ fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.1"), fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.0"), ]; - const res = wallet.selectPayCoins([], cds, a("EUR:2.0"), a("EUR:0.1")); + const res = selectPayCoins([], cds, a("EUR:2.0"), a("EUR:0.1")); if (!res) { t.fail(); return; @@ -87,15 +88,14 @@ test("coin selection 1", (t) => { t.pass(); }); - -test("coin selection 2", (t) => { +test("coin selection 2", t => { const cds: types.CoinWithDenom[] = [ fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"), fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.0"), // Merchant covers the fee, this one shouldn't be used fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.0"), ]; - const res = wallet.selectPayCoins([], cds, a("EUR:2.0"), a("EUR:0.5")); + const res = selectPayCoins([], cds, a("EUR:2.0"), a("EUR:0.5")); if (!res) { t.fail(); return; @@ -104,15 +104,14 @@ test("coin selection 2", (t) => { t.pass(); }); - -test("coin selection 3", (t) => { +test("coin selection 3", t => { const cds: types.CoinWithDenom[] = [ fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"), fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"), // this coin should be selected instead of previous one with fee fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.0"), ]; - const res = wallet.selectPayCoins([], cds, a("EUR:2.0"), a("EUR:0.5")); + const res = selectPayCoins([], cds, a("EUR:2.0"), a("EUR:0.5")); if (!res) { t.fail(); return; @@ -121,14 +120,13 @@ test("coin selection 3", (t) => { t.pass(); }); - -test("coin selection 4", (t) => { +test("coin selection 4", t => { const cds: types.CoinWithDenom[] = [ fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"), fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"), fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"), ]; - const res = wallet.selectPayCoins([], cds, a("EUR:2.0"), a("EUR:0.2")); + const res = selectPayCoins([], cds, a("EUR:2.0"), a("EUR:0.2")); if (!res) { t.fail(); return; @@ -137,25 +135,23 @@ test("coin selection 4", (t) => { t.pass(); }); - -test("coin selection 5", (t) => { +test("coin selection 5", t => { const cds: types.CoinWithDenom[] = [ fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"), fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"), fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"), ]; - const res = wallet.selectPayCoins([], cds, a("EUR:4.0"), a("EUR:0.2")); + const res = selectPayCoins([], cds, a("EUR:4.0"), a("EUR:0.2")); t.true(!res); t.pass(); }); - -test("coin selection 6", (t) => { +test("coin selection 6", t => { const cds: types.CoinWithDenom[] = [ fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"), fakeCwd("EUR:1.0", "EUR:1.0", "EUR:0.5"), ]; - const res = wallet.selectPayCoins([], cds, a("EUR:2.0"), a("EUR:0.2")); + const res = selectPayCoins([], cds, a("EUR:2.0"), a("EUR:0.2")); t.true(!res); t.pass(); }); diff --git a/src/wallet.ts b/src/wallet.ts index 8fe8d367d..91f6c0cca 100644 --- a/src/wallet.ts +++ b/src/wallet.ts @@ -1,17 +1,17 @@ /* - This file is part of TALER + This file is part of GNU Taler (C) 2015-2019 GNUnet e.V. - TALER is free software; you can redistribute it and/or modify it under the + GNU Taler is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. - TALER is distributed in the hope that it will be useful, but WITHOUT ANY + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with - TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ /** @@ -23,93 +23,59 @@ * Imports. */ import { CryptoApi, CryptoWorkerFactory } from "./crypto/cryptoApi"; +import { HttpRequestLibrary } from "./util/http"; import { - amountToPretty, - canonicalJson, - canonicalizeBaseUrl, - getTalerStampSec, - strcmp, - extractTalerStamp, - extractTalerStampOrThrow, -} from "./helpers"; -import { HttpRequestLibrary } from "./http"; -import * as LibtoolVersion from "./libtoolVersion"; -import { - TransactionAbort, oneShotPut, oneShotGet, runWithWriteTransaction, oneShotIter, oneShotIterIndex, - oneShotGetIndexed, - oneShotMutate, -} from "./query"; +} from "./util/query"; -import { AmountJson } from "./amounts"; -import * as Amounts from "./amounts"; +import { AmountJson } from "./util/amounts"; +import * as Amounts from "./util/amounts"; -import URI = require("urijs"); +import { + acceptWithdrawal, + getWithdrawalInfo, + getWithdrawDetailsForUri, + getWithdrawDetailsForAmount, +} from "./wallet-impl/withdraw"; + +import { + abortFailedPayment, + preparePay, + confirmPay, + SpeculativePayData, +} from "./wallet-impl/pay"; import { CoinRecord, CoinStatus, - CoinsReturnRecord, CurrencyRecord, DenominationRecord, - DenominationStatus, ExchangeRecord, PlanchetRecord, ProposalRecord, PurchaseRecord, - RefreshPlanchetRecord, - RefreshSessionRecord, ReserveRecord, Stores, - TipRecord, - WireFee, - WithdrawalSessionRecord, - ExchangeUpdateStatus, ReserveRecordStatus, - ProposalStatus, } from "./dbTypes"; -import { - Auditor, - ContractTerms, - Denomination, - ExchangeHandle, - ExchangeWireJson, - KeysJson, - MerchantRefundPermission, - MerchantRefundResponse, - PayReq, - PaybackConfirmation, - Proposal, - RefundRequest, - ReserveStatus, - TipPlanchetDetail, - TipResponse, - WithdrawOperationStatusResponse, - TipPickupGetResponse, -} from "./talerTypes"; +import { MerchantRefundPermission } from "./talerTypes"; import { Badge, BenchmarkResult, - CoinSelectionResult, - CoinWithDenom, ConfirmPayResult, ConfirmReserveRequest, CreateReserveRequest, CreateReserveResponse, HistoryEvent, - NextUrlResult, Notifier, - PayCoinInfo, - ReserveCreationInfo, ReturnCoinsRequest, SenderWireInfos, TipStatus, WalletBalance, - WalletBalanceEntry, PreparePayResult, DownloadedWithdrawInfo, WithdrawDetails, @@ -118,26 +84,32 @@ import { PendingOperationInfo, PendingOperationsResponse, HistoryQuery, - getTimestampNow, - OperationError, - Timestamp, } from "./walletTypes"; -import { - parsePayUri, - parseWithdrawUri, - parseTipUri, - parseRefundUri, -} from "./taleruri"; -import { Logger } from "./logging"; -import { randomBytes } from "./crypto/primitives/nacl-fast"; -import { encodeCrock, getRandomBytes } from "./crypto/talerCrypto"; +import { Logger } from "./util/logging"; -interface SpeculativePayData { - payCoinInfo: PayCoinInfo; - exchangeUrl: string; - orderDownloadId: string; - proposal: ProposalRecord; -} +import { assertUnreachable } from "./util/assertUnreachable"; + +import { applyRefund, getFullRefundFees } from "./wallet-impl/refund"; + +import { + updateExchangeFromUrl, + getExchangeTrust, + getExchangePaytoUri, +} from "./wallet-impl/exchanges"; +import { processReserve } from "./wallet-impl/reserves"; + +import { AsyncOpMemo } from "./util/asyncMemo"; + +import { InternalWalletState } from "./wallet-impl/state"; +import { createReserve, confirmReserve } from "./wallet-impl/reserves"; +import { processRefreshSession, refresh } from "./wallet-impl/refresh"; +import { processWithdrawSession } from "./wallet-impl/withdraw"; +import { getHistory } from "./wallet-impl/history"; +import { getPendingOperations } from "./wallet-impl/pending"; +import { getBalances } from "./wallet-impl/balance"; +import { acceptTip, getTipStatus } from "./wallet-impl/tip"; +import { returnCoins } from "./wallet-impl/return"; +import { payback } from "./wallet-impl/payback"; /** * Wallet protocol version spoken with the exchange @@ -147,7 +119,7 @@ interface SpeculativePayData { */ export const WALLET_PROTOCOL_VERSION = "3:0:0"; -const WALLET_CACHE_BREAKER_CLIENT_VERSION = "2"; +export const WALLET_CACHE_BREAKER_CLIENT_VERSION = "2"; const builtinCurrencies: CurrencyRecord[] = [ { @@ -164,186 +136,6 @@ 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; - return started && stillOkay; -} - -interface SelectPayCoinsResult { - cds: CoinWithDenom[]; - 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. - * - * If the amount left is zero, then the refresh cost - * is also considered to be zero. If a refresh isn't possible (e.g. due to lack of - * the right denominations), then the cost is the full amount left. - * - * Considers refresh fees, withdrawal fees after refresh and amounts too small - * to refresh. - */ -export function getTotalRefreshCost( - denoms: DenominationRecord[], - refreshedDenom: DenominationRecord, - amountLeft: AmountJson, -): AmountJson { - const withdrawAmount = Amounts.sub(amountLeft, refreshedDenom.feeRefresh) - .amount; - const withdrawDenoms = getWithdrawDenomList(withdrawAmount, denoms); - const resultingAmount = Amounts.add( - Amounts.getZero(withdrawAmount.currency), - ...withdrawDenoms.map(d => d.value), - ).amount; - const totalCost = Amounts.sub(amountLeft, resultingAmount).amount; - Wallet.enableTracing && - console.log( - "total refresh cost for", - amountToPretty(amountLeft), - "is", - amountToPretty(totalCost), - ); - return totalCost; -} - -/** - * Select coins for a payment under the merchant's constraints. - * - * @param denoms all available denoms, used to compute refresh fees - */ -export function selectPayCoins( - denoms: DenominationRecord[], - cds: CoinWithDenom[], - paymentAmount: AmountJson, - depositFeeLimit: AmountJson, -): SelectPayCoinsResult | undefined { - if (cds.length === 0) { - return undefined; - } - // Sort by ascending deposit fee and denomPub if deposit fee is the same - // (to guarantee deterministic results) - cds.sort( - (o1, o2) => - Amounts.cmp(o1.denom.feeDeposit, o2.denom.feeDeposit) || - strcmp(o1.denom.denomPub, o2.denom.denomPub), - ); - const currency = cds[0].denom.value.currency; - const cdsResult: CoinWithDenom[] = []; - let accDepositFee: AmountJson = Amounts.getZero(currency); - let accAmount: AmountJson = Amounts.getZero(currency); - for (const { coin, denom } of cds) { - if (coin.suspended) { - continue; - } - if (coin.status !== CoinStatus.Fresh) { - continue; - } - if (Amounts.cmp(denom.feeDeposit, coin.currentAmount) >= 0) { - continue; - } - cdsResult.push({ coin, denom }); - accDepositFee = Amounts.add(denom.feeDeposit, accDepositFee).amount; - let leftAmount = Amounts.sub( - coin.currentAmount, - Amounts.sub(paymentAmount, accAmount).amount, - ).amount; - accAmount = Amounts.add(coin.currentAmount, accAmount).amount; - const coversAmount = Amounts.cmp(accAmount, paymentAmount) >= 0; - const coversAmountWithFee = - Amounts.cmp( - accAmount, - Amounts.add(paymentAmount, denom.feeDeposit).amount, - ) >= 0; - const isBelowFee = Amounts.cmp(accDepositFee, depositFeeLimit) <= 0; - - Wallet.enableTracing && - console.log("candidate coin selection", { - coversAmount, - isBelowFee, - accDepositFee, - accAmount, - paymentAmount, - }); - - if ((coversAmount && isBelowFee) || coversAmountWithFee) { - const depositFeeToCover = Amounts.sub(accDepositFee, depositFeeLimit) - .amount; - leftAmount = Amounts.sub(leftAmount, depositFeeToCover).amount; - Wallet.enableTracing && - console.log("deposit fee to cover", amountToPretty(depositFeeToCover)); - - let totalFees: AmountJson = Amounts.getZero(currency); - if (coversAmountWithFee && !isBelowFee) { - // these are the fees the customer has to pay - // because the merchant doesn't cover them - totalFees = Amounts.sub(depositFeeLimit, accDepositFee).amount; - } - totalFees = Amounts.add( - totalFees, - getTotalRefreshCost(denoms, denom, leftAmount), - ).amount; - return { cds: cdsResult, totalFees }; - } - } - return undefined; -} - -/** - * Get a list of denominations (with repetitions possible) - * whose total value is as close as possible to the available - * amount, but never larger. - */ -function getWithdrawDenomList( - amountAvailable: AmountJson, - denoms: DenominationRecord[], -): DenominationRecord[] { - let remaining = Amounts.copy(amountAvailable); - const ds: DenominationRecord[] = []; - - denoms = denoms.filter(isWithdrawableDenom); - denoms.sort((d1, d2) => Amounts.cmp(d2.value, d1.value)); - - // This is an arbitrary number of coins - // we can withdraw in one go. It's not clear if this limit - // is useful ... - for (let i = 0; i < 1000; i++) { - let found = false; - for (const d of denoms) { - const cost = Amounts.add(d.value, d.feeWithdraw).amount; - if (Amounts.cmp(remaining, cost) < 0) { - continue; - } - found = true; - remaining = Amounts.sub(remaining, cost).amount; - ds.push(d); - break; - } - if (!found) { - break; - } - } - return ds; -} - -interface CoinsForPaymentArgs { - allowedAuditors: Auditor[]; - allowedExchanges: ExchangeHandle[]; - depositFeeLimit: AmountJson; - paymentAmount: AmountJson; - wireFeeAmortization: number; - wireFeeLimit: AmountJson; - wireFeeTime: Timestamp; - wireMethod: string; -} - /** * This error is thrown when an */ @@ -358,60 +150,27 @@ export class OperationFailedAndReportedError extends Error { const logger = new Logger("wallet.ts"); -interface MemoEntry<T> { - p: Promise<T>; - t: number; - n: number; -} - -class AsyncOpMemo<T> { - n = 0; - memo: { [k: string]: MemoEntry<T> } = {}; - put(key: string, p: Promise<T>): Promise<T> { - 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<T> | 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. */ export class Wallet { - /** - * IndexedDB database used by the wallet. - */ - db: IDBDatabase; - static enableTracing = false; - private http: HttpRequestLibrary; - private badge: Badge; - private notifier: Notifier; - private cryptoApi: CryptoApi; - private speculativePayData: SpeculativePayData | undefined; - private cachedNextUrl: { [fulfillmentUrl: string]: NextUrlResult } = {}; + private ws: InternalWalletState; + + get db(): IDBDatabase { + return this.ws.db; + } - private memoProcessReserve = new AsyncOpMemo<void>(); + private get badge(): Badge { + return this.ws.badge; + } + + private get cryptoApi(): CryptoApi { + return this.ws.cryptoApi; + } + + private get notifier(): Notifier { + return this.ws.notifier; + } constructor( db: IDBDatabase, @@ -420,11 +179,25 @@ export class Wallet { notifier: Notifier, cryptoWorkerFactory: CryptoWorkerFactory, ) { - this.db = db; - this.http = http; - this.badge = badge; - this.notifier = notifier; - this.cryptoApi = new CryptoApi(cryptoWorkerFactory); + this.ws = { + badge, + cachedNextUrl: {}, + cryptoApi: new CryptoApi(cryptoWorkerFactory), + db, + http, + notifier, + speculativePayData: undefined, + memoProcessReserve: new AsyncOpMemo<void>(), + }; + } + + getExchangePaytoUri(exchangeBaseUrl: string, supportedTargetTypes: string[]) { + return getExchangePaytoUri(this.ws, exchangeBaseUrl, supportedTargetTypes); + } + + + getWithdrawDetailsForAmount(baseUrl: any, amount: AmountJson): any { + return getWithdrawDetailsForAmount(this.ws, baseUrl, amount); } /** @@ -443,7 +216,7 @@ export class Wallet { await this.updateExchangeFromUrl(pending.exchangeBaseUrl); break; case "planchet": - await this.processPlanchet(pending.coinPub); + // Nothing to do, since the withdraw session will process the planchet break; case "refresh": await this.processRefreshSession(pending.refreshSessionId); @@ -535,272 +308,6 @@ export class Wallet { ); } - private async getCoinsForReturn( - exchangeBaseUrl: string, - amount: AmountJson, - ): Promise<CoinWithDenom[] | undefined> { - const exchange = await oneShotGet( - this.db, - Stores.exchanges, - exchangeBaseUrl, - ); - if (!exchange) { - throw Error(`Exchange ${exchangeBaseUrl} not known to the wallet`); - } - - const coins: CoinRecord[] = await oneShotIterIndex( - this.db, - Stores.coins.exchangeBaseUrlIndex, - exchange.baseUrl, - ).toArray(); - - if (!coins || !coins.length) { - return []; - } - - const denoms = await oneShotIterIndex( - this.db, - Stores.denominations.exchangeBaseUrlIndex, - exchange.baseUrl, - ).toArray(); - - // Denomination of the first coin, we assume that all other - // coins have the same currency - const firstDenom = await oneShotGet(this.db, Stores.denominations, [ - exchange.baseUrl, - coins[0].denomPub, - ]); - if (!firstDenom) { - throw Error("db inconsistent"); - } - const currency = firstDenom.value.currency; - - const cds: CoinWithDenom[] = []; - for (const coin of coins) { - const denom = await oneShotGet(this.db, Stores.denominations, [ - exchange.baseUrl, - coin.denomPub, - ]); - if (!denom) { - throw Error("db inconsistent"); - } - if (denom.value.currency !== currency) { - console.warn( - `same pubkey for different currencies at exchange ${exchange.baseUrl}`, - ); - continue; - } - if (coin.suspended) { - continue; - } - if (coin.status !== CoinStatus.Fresh) { - continue; - } - cds.push({ coin, denom }); - } - - const res = selectPayCoins(denoms, cds, amount, amount); - if (res) { - return res.cds; - } - return undefined; - } - - /** - * Get exchanges and associated coins that are still spendable, but only - * if the sum the coins' remaining value covers the payment amount and fees. - */ - private async getCoinsForPayment( - args: CoinsForPaymentArgs, - ): Promise<CoinSelectionResult | undefined> { - const { - allowedAuditors, - allowedExchanges, - depositFeeLimit, - paymentAmount, - wireFeeAmortization, - wireFeeLimit, - wireFeeTime, - wireMethod, - } = args; - - let remainingAmount = paymentAmount; - - const exchanges = await oneShotIter(this.db, Stores.exchanges).toArray(); - - for (const exchange of exchanges) { - let isOkay: boolean = false; - const exchangeDetails = exchange.details; - if (!exchangeDetails) { - continue; - } - const exchangeFees = exchange.wireInfo; - if (!exchangeFees) { - continue; - } - - // is the exchange explicitly allowed? - for (const allowedExchange of allowedExchanges) { - if (allowedExchange.master_pub === exchangeDetails.masterPublicKey) { - isOkay = true; - break; - } - } - - // is the exchange allowed because of one of its auditors? - if (!isOkay) { - for (const allowedAuditor of allowedAuditors) { - for (const auditor of exchangeDetails.auditors) { - if (auditor.auditor_pub === allowedAuditor.auditor_pub) { - isOkay = true; - break; - } - } - if (isOkay) { - break; - } - } - } - - if (!isOkay) { - continue; - } - - const coins = await oneShotIterIndex( - this.db, - Stores.coins.exchangeBaseUrlIndex, - exchange.baseUrl, - ).toArray(); - - const denoms = await oneShotIterIndex( - this.db, - Stores.denominations.exchangeBaseUrlIndex, - exchange.baseUrl, - ).toArray(); - - if (!coins || coins.length === 0) { - continue; - } - - // Denomination of the first coin, we assume that all other - // coins have the same currency - const firstDenom = await oneShotGet(this.db, Stores.denominations, [ - exchange.baseUrl, - coins[0].denomPub, - ]); - if (!firstDenom) { - throw Error("db inconsistent"); - } - const currency = firstDenom.value.currency; - const cds: CoinWithDenom[] = []; - for (const coin of coins) { - const denom = await oneShotGet(this.db, Stores.denominations, [ - exchange.baseUrl, - coin.denomPub, - ]); - if (!denom) { - throw Error("db inconsistent"); - } - if (denom.value.currency !== currency) { - console.warn( - `same pubkey for different currencies at exchange ${exchange.baseUrl}`, - ); - continue; - } - if (coin.suspended) { - continue; - } - if (coin.status !== CoinStatus.Fresh) { - continue; - } - cds.push({ coin, denom }); - } - - let totalFees = Amounts.getZero(currency); - let wireFee: AmountJson | undefined; - for (const fee of exchangeFees.feesForType[wireMethod] || []) { - if (fee.startStamp <= wireFeeTime && fee.endStamp >= wireFeeTime) { - wireFee = fee.wireFee; - break; - } - } - - if (wireFee) { - const amortizedWireFee = Amounts.divide(wireFee, wireFeeAmortization); - if (Amounts.cmp(wireFeeLimit, amortizedWireFee) < 0) { - totalFees = Amounts.add(amortizedWireFee, totalFees).amount; - remainingAmount = Amounts.add(amortizedWireFee, remainingAmount) - .amount; - } - } - - const res = selectPayCoins(denoms, cds, remainingAmount, depositFeeLimit); - - if (res) { - totalFees = Amounts.add(totalFees, res.totalFees).amount; - return { - cds: res.cds, - exchangeUrl: exchange.baseUrl, - totalAmount: remainingAmount, - totalFees, - }; - } - } - return undefined; - } - - /** - * Record all information that is necessary to - * pay for a proposal in the wallet's database. - */ - private async recordConfirmPay( - proposal: ProposalRecord, - payCoinInfo: PayCoinInfo, - chosenExchange: string, - ): Promise<PurchaseRecord> { - const payReq: PayReq = { - coins: payCoinInfo.sigs, - merchant_pub: proposal.contractTerms.merchant_pub, - mode: "pay", - order_id: proposal.contractTerms.order_id, - }; - const t: PurchaseRecord = { - abortDone: false, - abortRequested: false, - contractTerms: proposal.contractTerms, - contractTermsHash: proposal.contractTermsHash, - finished: false, - lastSessionId: undefined, - merchantSig: proposal.merchantSig, - payReq, - refundsDone: {}, - refundsPending: {}, - timestamp: getTimestampNow(), - timestamp_refund: undefined, - }; - - await runWithWriteTransaction( - this.db, - [Stores.coins, Stores.purchases], - async tx => { - await tx.put(Stores.purchases, t); - for (let c of payCoinInfo.updatedCoins) { - await tx.put(Stores.coins, c); - } - }, - ); - - this.badge.showNotification(); - this.notifier.notify(); - return t; - } - - getNextUrl(contractTerms: ContractTerms): string { - const fu = new URI(contractTerms.fulfillment_url); - fu.addSearch("order_id", contractTerms.order_id); - return fu.href(); - } - /** * Check if a payment for the given taler://pay/ URI is possible. * @@ -808,305 +315,7 @@ export class Wallet { * yet send to the merchant. */ async preparePay(talerPayUri: string): Promise<PreparePayResult> { - const uriResult = parsePayUri(talerPayUri); - - if (!uriResult) { - return { - status: "error", - error: "URI not supported", - }; - } - - let proposalId: string; - try { - proposalId = await this.downloadProposal( - uriResult.downloadUrl, - uriResult.sessionId, - ); - } catch (e) { - return { - status: "error", - error: e.toString(), - }; - } - const proposal = await this.getProposal(proposalId); - if (!proposal) { - throw Error(`could not get proposal ${proposalId}`); - } - - console.log("proposal", proposal); - - const differentPurchase = await oneShotGetIndexed( - this.db, - Stores.purchases.fulfillmentUrlIndex, - proposal.contractTerms.fulfillment_url, - ); - - 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. - if ( - differentPurchase.contractTerms.merchant_pub != - proposal.contractTerms.merchant_pub - ) { - console.warn( - "merchant with different public key offered contract with same fulfillment URL as an existing purchase", - ); - } else { - if (uriResult.sessionId) { - await this.submitPay( - differentPurchase.contractTermsHash, - uriResult.sessionId, - ); - } - return { - status: "paid", - contractTerms: differentPurchase.contractTerms, - nextUrl: this.getNextUrl(differentPurchase.contractTerms), - }; - } - } - - // First check if we already payed for it. - const purchase = await oneShotGet( - this.db, - Stores.purchases, - proposal.contractTermsHash, - ); - - if (!purchase) { - const paymentAmount = Amounts.parseOrThrow(proposal.contractTerms.amount); - let wireFeeLimit; - if (proposal.contractTerms.max_wire_fee) { - wireFeeLimit = Amounts.parseOrThrow( - proposal.contractTerms.max_wire_fee, - ); - } else { - wireFeeLimit = Amounts.getZero(paymentAmount.currency); - } - // If not already payed, check if we could pay for it. - const res = await this.getCoinsForPayment({ - allowedAuditors: proposal.contractTerms.auditors, - allowedExchanges: proposal.contractTerms.exchanges, - depositFeeLimit: Amounts.parseOrThrow(proposal.contractTerms.max_fee), - paymentAmount, - wireFeeAmortization: proposal.contractTerms.wire_fee_amortization || 1, - wireFeeLimit, - // FIXME: parse this properly - wireFeeTime: extractTalerStamp(proposal.contractTerms.timestamp) || { - t_ms: 0, - }, - wireMethod: proposal.contractTerms.wire_method, - }); - - if (!res) { - console.log("not confirming payment, insufficient coins"); - return { - status: "insufficient-balance", - contractTerms: proposal.contractTerms, - proposalId: proposal.proposalId, - }; - } - - // Only create speculative signature if we don't already have one for this proposal - if ( - !this.speculativePayData || - (this.speculativePayData && - this.speculativePayData.orderDownloadId !== proposalId) - ) { - const { exchangeUrl, cds, totalAmount } = res; - const payCoinInfo = await this.cryptoApi.signDeposit( - proposal.contractTerms, - cds, - totalAmount, - ); - this.speculativePayData = { - exchangeUrl, - payCoinInfo, - proposal, - orderDownloadId: proposalId, - }; - Wallet.enableTracing && - console.log("created speculative pay data for payment"); - } - - return { - status: "payment-possible", - contractTerms: proposal.contractTerms, - proposalId: proposal.proposalId, - totalFees: res.totalFees, - }; - } - - if (uriResult.sessionId) { - await this.submitPay(purchase.contractTermsHash, uriResult.sessionId); - } - - return { - status: "paid", - contractTerms: proposal.contractTerms, - nextUrl: this.getNextUrl(purchase.contractTerms), - }; - } - - /** - * Download a proposal and store it in the database. - * Returns an id for it to retrieve it later. - * - * @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<string> { - const oldProposal = await oneShotGetIndexed( - this.db, - Stores.proposals.urlIndex, - url, - ); - if (oldProposal) { - return oldProposal.proposalId; - } - - const { priv, pub } = await this.cryptoApi.createEddsaKeypair(); - const parsed_url = new URI(url); - const urlWithNonce = parsed_url.setQuery({ nonce: pub }).href(); - console.log("downloading contract from '" + urlWithNonce + "'"); - let resp; - try { - resp = await this.http.get(urlWithNonce); - } catch (e) { - console.log("contract download failed", e); - throw e; - } - - const proposal = Proposal.checked(resp.responseJson); - - const contractTermsHash = await this.hashContract(proposal.contract_terms); - - const proposalId = encodeCrock(getRandomBytes(32)); - - const proposalRecord: ProposalRecord = { - contractTerms: proposal.contract_terms, - contractTermsHash, - merchantSig: proposal.sig, - noncePriv: priv, - timestamp: getTimestampNow(), - url, - downloadSessionId: sessionId, - proposalId: proposalId, - proposalStatus: ProposalStatus.PROPOSED, - }; - await oneShotPut(this.db, Stores.proposals, proposalRecord); - this.notifier.notify(); - - return proposalId; - } - - async refundFailedPay(proposalId: number) { - console.log(`refunding failed payment with proposal id ${proposalId}`); - const proposal = await oneShotGet(this.db, Stores.proposals, proposalId); - if (!proposal) { - throw Error(`proposal with id ${proposalId} not found`); - } - - const purchase = await oneShotGet( - this.db, - Stores.purchases, - proposal.contractTermsHash, - ); - - if (!purchase) { - throw Error("purchase not found for proposal"); - } - - if (purchase.finished) { - throw Error("can't auto-refund finished purchase"); - } - } - - async submitPay( - contractTermsHash: string, - sessionId: string | undefined, - ): Promise<ConfirmPayResult> { - const purchase = await oneShotGet( - this.db, - Stores.purchases, - contractTermsHash, - ); - if (!purchase) { - throw Error("Purchase not found: " + contractTermsHash); - } - if (purchase.abortRequested) { - throw Error("not submitting payment for aborted purchase"); - } - let resp; - const payReq = { ...purchase.payReq, session_id: sessionId }; - - const payUrl = new URI("pay") - .absoluteTo(purchase.contractTerms.merchant_base_url) - .href(); - - try { - resp = await this.http.postJson(payUrl, payReq); - } catch (e) { - // Gives the user the option to retry / abort and refresh - console.log("payment failed", e); - throw e; - } - const merchantResp = resp.responseJson; - console.log("got success from pay URL"); - - const merchantPub = purchase.contractTerms.merchant_pub; - const valid: boolean = await this.cryptoApi.isValidPaymentSignature( - merchantResp.sig, - contractTermsHash, - merchantPub, - ); - if (!valid) { - console.error("merchant payment signature invalid"); - // FIXME: properly display error - throw Error("merchant payment signature invalid"); - } - purchase.finished = true; - const modifiedCoins: CoinRecord[] = []; - for (const pc of purchase.payReq.coins) { - const c = await oneShotGet(this.db, Stores.coins, pc.coin_pub); - if (!c) { - console.error("coin not found"); - throw Error("coin used in payment not found"); - } - c.status = CoinStatus.Dirty; - modifiedCoins.push(c); - } - - await runWithWriteTransaction( - this.db, - [Stores.coins, Stores.purchases], - async tx => { - for (let c of modifiedCoins) { - tx.put(Stores.coins, c); - } - tx.put(Stores.purchases, purchase); - }, - ); - - for (const c of purchase.payReq.coins) { - this.refresh(c.coin_pub); - } - - const nextUrl = this.getNextUrl(purchase.contractTerms); - this.cachedNextUrl[purchase.contractTerms.fulfillment_url] = { - nextUrl, - lastSessionId: sessionId, - }; - - return { nextUrl }; + return preparePay(this.ws, talerPayUri); } /** @@ -1138,217 +347,7 @@ export class Wallet { proposalId: string, sessionIdOverride: string | undefined, ): Promise<ConfirmPayResult> { - Wallet.enableTracing && - console.log( - `executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`, - ); - const proposal = await oneShotGet(this.db, Stores.proposals, proposalId); - - if (!proposal) { - throw Error(`proposal with id ${proposalId} not found`); - } - - const sessionId = sessionIdOverride || proposal.downloadSessionId; - - let purchase = await oneShotGet( - this.db, - Stores.purchases, - proposal.contractTermsHash, - ); - - if (purchase) { - return this.submitPay(purchase.contractTermsHash, sessionId); - } - - const contractAmount = Amounts.parseOrThrow(proposal.contractTerms.amount); - - let wireFeeLimit; - if (!proposal.contractTerms.max_wire_fee) { - wireFeeLimit = Amounts.getZero(contractAmount.currency); - } else { - wireFeeLimit = Amounts.parseOrThrow(proposal.contractTerms.max_wire_fee); - } - - const res = await this.getCoinsForPayment({ - allowedAuditors: proposal.contractTerms.auditors, - allowedExchanges: proposal.contractTerms.exchanges, - depositFeeLimit: Amounts.parseOrThrow(proposal.contractTerms.max_fee), - paymentAmount: Amounts.parseOrThrow(proposal.contractTerms.amount), - wireFeeAmortization: proposal.contractTerms.wire_fee_amortization || 1, - wireFeeLimit, - // FIXME: parse this properly - wireFeeTime: extractTalerStamp(proposal.contractTerms.timestamp) || { - t_ms: 0, - }, - wireMethod: proposal.contractTerms.wire_method, - }); - - Wallet.enableTracing && console.log("coin selection result", res); - - if (!res) { - // Should not happen, since checkPay should be called first - console.log("not confirming payment, insufficient coins"); - throw Error("insufficient balance"); - } - - const sd = await this.getSpeculativePayData(proposalId); - if (!sd) { - const { exchangeUrl, cds, totalAmount } = res; - const payCoinInfo = await this.cryptoApi.signDeposit( - proposal.contractTerms, - cds, - totalAmount, - ); - purchase = await this.recordConfirmPay( - proposal, - payCoinInfo, - exchangeUrl, - ); - } else { - purchase = await this.recordConfirmPay( - sd.proposal, - sd.payCoinInfo, - sd.exchangeUrl, - ); - } - - return this.submitPay(purchase.contractTermsHash, sessionId); - } - - /** - * Get the speculative pay data, but only if coins have not changed in between. - */ - async getSpeculativePayData( - proposalId: string, - ): Promise<SpeculativePayData | undefined> { - const sp = this.speculativePayData; - if (!sp) { - return; - } - if (sp.orderDownloadId !== proposalId) { - return; - } - const coinKeys = sp.payCoinInfo.updatedCoins.map(x => x.coinPub); - const coins: CoinRecord[] = []; - for (let coinKey of coinKeys) { - const cc = await oneShotGet(this.db, Stores.coins, coinKey); - if (cc) { - coins.push(cc); - } - } - for (let i = 0; i < coins.length; i++) { - const specCoin = sp.payCoinInfo.originalCoins[i]; - const currentCoin = coins[i]; - - // Coin does not exist anymore! - if (!currentCoin) { - return; - } - if ( - Amounts.cmp(specCoin.currentAmount, currentCoin.currentAmount) !== 0 - ) { - return; - } - } - return sp; - } - - private async processReserveBankStatus(reservePub: string): Promise<void> { - 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; - } - - let status: WithdrawOperationStatusResponse; - try { - const statusResp = await this.http.get(bankStatusUrl); - status = WithdrawOperationStatusResponse.checked(statusResp.responseJson); - } catch (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; - }); - await this.processReserveImpl(reservePub); - } else { - await oneShotMutate(this.db, Stores.reserves, reservePub, r => { - 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); + return confirmPay(this.ws, proposalId, sessionIdOverride); } /** @@ -1359,149 +358,7 @@ export class Wallet { * state DORMANT. */ async processReserve(reservePub: string): Promise<void> { - 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<void> { - const reserve = await oneShotGet(this.db, Stores.reserves, reservePub); - if (!reserve) { - console.log("not processing reserve: reserve does not exist"); - return; - } - 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.processReserveBankStatus(reservePub); - return this.processReserveImpl(reservePub); - case ReserveRecordStatus.QUERYING_STATUS: - await this.updateReserve(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; - } - } - - /** - * Given a planchet, withdraw a coin from the exchange. - */ - private async processPlanchet(coinPub: string): Promise<void> { - 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, - planchet.exchangeBaseUrl, - ); - if (!exchange) { - console.error("db inconsistent: exchange for planchet not found"); - return; - } - - const denom = await oneShotGet(this.db, Stores.denominations, [ - planchet.exchangeBaseUrl, - planchet.denomPub, - ]); - - if (!denom) { - console.error("db inconsistent: denom for planchet not found"); - return; - } - - const wd: any = {}; - 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); - - const r = resp.responseJson; - - const denomSig = await this.cryptoApi.rsaUnblind( - r.ev_sig, - planchet.blindingKey, - planchet.denomPub, - ); - - const coin: CoinRecord = { - blindingKey: planchet.blindingKey, - coinPriv: planchet.coinPriv, - coinPub: planchet.coinPub, - currentAmount: planchet.coinValue, - denomPub: planchet.denomPub, - denomPubHash: planchet.denomPubHash, - denomSig, - exchangeBaseUrl: planchet.exchangeBaseUrl, - reservePub: planchet.reservePub, - status: CoinStatus.Fresh, - coinIndex: planchet.coinIndex, - withdrawSessionId: planchet.withdrawSessionId, - }; - - await runWithWriteTransaction( - this.db, - [Stores.planchets, Stores.coins, Stores.withdrawalSession, Stores.reserves], - async tx => { - const currentPc = await tx.get(Stores.planchets, coin.coinPub); - if (!currentPc) { - return; - } - 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`); + return processReserve(this.ws, reservePub); } /** @@ -1513,119 +370,7 @@ export class Wallet { async createReserve( req: CreateReserveRequest, ): Promise<CreateReserveResponse> { - const keypair = await this.cryptoApi.createEddsaKeypair(); - const now = getTimestampNow(); - const canonExchange = canonicalizeBaseUrl(req.exchange); - - let reserveStatus; - if (req.bankWithdrawStatusUrl) { - reserveStatus = ReserveRecordStatus.REGISTERING_BANK; - } else { - reserveStatus = ReserveRecordStatus.UNCONFIRMED; - } - - const currency = req.amount.currency; - - const reserveRecord: ReserveRecord = { - created: now, - withdrawAllocatedAmount: Amounts.getZero(currency), - withdrawCompletedAmount: Amounts.getZero(currency), - withdrawRemainingAmount: Amounts.getZero(currency), - exchangeBaseUrl: canonExchange, - hasPayback: false, - initiallyRequestedAmount: req.amount, - reservePriv: keypair.priv, - reservePub: keypair.pub, - senderWire: req.senderWire, - timestampConfirmed: undefined, - timestampReserveInfoPosted: undefined, - bankWithdrawStatusUrl: req.bankWithdrawStatusUrl, - exchangeWire: req.exchangeWire, - reserveStatus, - lastStatusQuery: undefined, - }; - - const senderWire = req.senderWire; - if (senderWire) { - const rec = { - paytoUri: senderWire, - }; - await oneShotPut(this.db, Stores.senderWires, rec); - } - - const exchangeInfo = await this.updateExchangeFromUrl(req.exchange); - const exchangeDetails = exchangeInfo.details; - if (!exchangeDetails) { - throw Error("exchange not updated"); - } - const { isAudited, isTrusted } = await this.getExchangeTrust(exchangeInfo); - let currencyRecord = await oneShotGet( - this.db, - Stores.currencies, - exchangeDetails.currency, - ); - if (!currencyRecord) { - currencyRecord = { - auditors: [], - exchanges: [], - fractionalDigits: 2, - name: exchangeDetails.currency, - }; - } - - if (!isAudited && !isTrusted) { - currencyRecord.exchanges.push({ - baseUrl: req.exchange, - exchangePub: exchangeDetails.masterPublicKey, - }); - } - - const cr: CurrencyRecord = currencyRecord; - - const resp = await runWithWriteTransaction( - this.db, - [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; - }, - ); - - // Asynchronously process the reserve, but return - // to the caller already. - this.processReserve(resp.reservePub).catch(e => { - console.error("Processing reserve failed:", e); - }); - - return resp; + return createReserve(this.ws, req); } /** @@ -1638,436 +383,13 @@ export class Wallet { * an unconfirmed reserve should be hidden. */ async confirmReserve(req: ConfirmReserveRequest): Promise<void> { - 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; - }); - - this.notifier.notify(); - - this.processReserve(req.reservePub).catch(e => { - console.log("processing reserve failed:", e); - }); - } - - /** - * Withdraw coins from a reserve until it is empty. - * - * When finished, marks the reserve as depleted by setting - * the depleted timestamp. - */ - private async depleteReserve(reservePub: string): Promise<void> { - const reserve = await oneShotGet(this.db, Stores.reserves, reservePub); - if (!reserve) { - return; - } - if (reserve.reserveStatus !== ReserveRecordStatus.WITHDRAWING) { - return; - } - logger.trace(`depleting reserve ${reservePub}`); - - 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, { - type: "internal", - message: m, - details: {}, - }); - console.log(m); - throw new OperationFailedAndReportedError(m); - } - - logger.trace("selected denominations"); - - const withdrawalSessionId = encodeCrock(randomBytes(32)); - - const withdrawalRecord: WithdrawalSessionRecord = { - withdrawSessionId: withdrawalSessionId, - reservePub: reserve.reservePub, - withdrawalAmount: Amounts.toString(withdrawAmount), - startTimestamp: getTimestampNow(), - denoms: denomsForWithdraw.map(x => x.denomPub), - withdrawn: denomsForWithdraw.map(x => false), - planchetCreated: denomsForWithdraw.map(x => false), - }; - - const totalCoinValue = Amounts.sum(denomsForWithdraw.map(x => x.value)) - .amount; - const totalCoinWithdrawFee = Amounts.sum( - denomsForWithdraw.map(x => x.feeWithdraw), - ).amount; - const totalWithdrawAmount = Amounts.add( - totalCoinValue, - totalCoinWithdrawFee, - ).amount; - - function mutateReserve(r: ReserveRecord): ReserveRecord { - const remaining = Amounts.sub( - r.withdrawRemainingAmount, - totalWithdrawAmount, - ); - if (remaining.saturated) { - console.error("can't create planchets, saturated"); - throw TransactionAbort; - } - 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; - } - - const success = await runWithWriteTransaction( - this.db, - [Stores.planchets, Stores.withdrawalSession, Stores.reserves], - async tx => { - const myReserve = await tx.get(Stores.reserves, reservePub); - if (!myReserve) { - return false; - } - if (myReserve.reserveStatus !== ReserveRecordStatus.WITHDRAWING) { - return false; - } - await tx.mutate(Stores.reserves, reserve.reservePub, mutateReserve); - await tx.put(Stores.withdrawalSession, withdrawalRecord); - return true; - }, - ); - - if (success) { - console.log("processing new withdraw session"); - await this.processWithdrawSession(withdrawalSessionId); - } else { - console.trace("withdraw session already existed"); - } + return confirmReserve(this.ws, req); } - private async processWithdrawSession(withdrawalSessionId: string): Promise<void> { - 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( + private async processWithdrawSession( 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); - } - } - - /** - * Update the information about a reserve that is stored in the wallet - * by quering the reserve's exchange. - */ - private async updateReserve(reservePub: string): Promise<void> { - const reserve = await oneShotGet(this.db, Stores.reserves, reservePub); - if (!reserve) { - throw Error("reserve not in db"); - } - - 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.exchangeBaseUrl, - ); - reqUrl.query({ reserve_pub: reservePub }); - let resp; - try { - resp = await this.http.get(reqUrl.href()); - } catch (e) { - if (e.response?.status === 404) { - return; - } else { - const m = e.message; - this.setReserveError(reservePub, { - type: "network", - details: {}, - message: m, - }); - throw new OperationFailedAndReportedError(m); - } - } - 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; - } - - // 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; - }); - this.notifier.notify(); - } - - async getPossibleDenoms( - exchangeBaseUrl: string, - ): Promise<DenominationRecord[]> { - return await oneShotIterIndex( - this.db, - Stores.denominations.exchangeBaseUrlIndex, - exchangeBaseUrl, - ).filter(d => { - return ( - d.status === DenominationStatus.Unverified || - d.status === DenominationStatus.VerifiedGood - ); - }); - } - - /** - * Compute the smallest withdrawable amount possible, based on verified denominations. - * - * Writes to the DB in order to record the result from verifying - * denominations. - */ - async getVerifiedSmallestWithdrawAmount( - exchangeBaseUrl: string, - ): Promise<AmountJson> { - const exchange = await oneShotGet( - this.db, - Stores.exchanges, - exchangeBaseUrl, - ); - if (!exchange) { - throw Error(`exchange ${exchangeBaseUrl} not found`); - } - const exchangeDetails = exchange.details; - if (!exchangeDetails) { - throw Error(`exchange ${exchangeBaseUrl} details not available`); - } - - const possibleDenoms = await this.getPossibleDenoms(exchange.baseUrl); - - possibleDenoms.sort((d1, d2) => { - const a1 = Amounts.add(d1.feeWithdraw, d1.value).amount; - const a2 = Amounts.add(d2.feeWithdraw, d2.value).amount; - return Amounts.cmp(a1, a2); - }); - - for (const denom of possibleDenoms) { - if (denom.status === DenominationStatus.VerifiedGood) { - return Amounts.add(denom.feeWithdraw, denom.value).amount; - } - const valid = await this.cryptoApi.isValidDenom( - denom, - exchangeDetails.masterPublicKey, - ); - if (!valid) { - denom.status = DenominationStatus.VerifiedBad; - } else { - denom.status = DenominationStatus.VerifiedGood; - } - await oneShotPut(this.db, Stores.denominations, denom); - if (valid) { - return Amounts.add(denom.feeWithdraw, denom.value).amount; - } - } - return Amounts.getZero(exchangeDetails.currency); - } - - /** - * Get a list of denominations to withdraw from the given exchange for the - * given amount, making sure that all denominations' signatures are verified. - * - * Writes to the DB in order to record the result from verifying - * denominations. - */ - async getVerifiedWithdrawDenomList( - exchangeBaseUrl: string, - amount: AmountJson, - ): Promise<DenominationRecord[]> { - const exchange = await oneShotGet( - this.db, - Stores.exchanges, - 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[]; - - do { - 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; - } else { - denom.status = DenominationStatus.VerifiedGood; - nextPossibleDenoms.push(denom); - } - await oneShotPut(this.db, Stores.denominations, denom); - } else { - nextPossibleDenoms.push(denom); - } - } - } while (selectedDenoms.length > 0 && !allValid); - - console.log("returning denoms"); - - return selectedDenoms; + ): Promise<void> { + return processWithdrawSession(this.ws, withdrawalSessionId); } /** @@ -2076,183 +398,18 @@ export class Wallet { async getExchangeTrust( exchangeInfo: ExchangeRecord, ): Promise<{ isTrusted: boolean; isAudited: boolean }> { - let isTrusted = false; - let isAudited = false; - const exchangeDetails = exchangeInfo.details; - if (!exchangeDetails) { - throw Error(`exchange ${exchangeInfo.baseUrl} details not available`); - } - const currencyRecord = await oneShotGet( - this.db, - Stores.currencies, - exchangeDetails.currency, - ); - if (currencyRecord) { - for (const trustedExchange of currencyRecord.exchanges) { - if (trustedExchange.exchangePub === exchangeDetails.masterPublicKey) { - isTrusted = true; - break; - } - } - for (const trustedAuditor of currencyRecord.auditors) { - for (const exchangeAuditor of exchangeDetails.auditors) { - if (trustedAuditor.auditorPub === exchangeAuditor.auditor_pub) { - isAudited = true; - break; - } - } - } - } - return { isTrusted, isAudited }; + return getExchangeTrust(this.ws, exchangeInfo); } async getWithdrawDetailsForUri( talerWithdrawUri: string, maybeSelectedExchange?: string, ): Promise<WithdrawDetails> { - const info = await this.getWithdrawalInfo(talerWithdrawUri); - let rci: ReserveCreationInfo | undefined = undefined; - if (maybeSelectedExchange) { - rci = await this.getWithdrawDetailsForAmount( - maybeSelectedExchange, - info.amount, - ); - } - return { - withdrawInfo: info, - reserveCreationInfo: rci, - }; - } - - async getWithdrawDetailsForAmount( - baseUrl: string, - amount: AmountJson, - ): Promise<ReserveCreationInfo> { - const exchangeInfo = await this.updateExchangeFromUrl(baseUrl); - const exchangeDetails = exchangeInfo.details; - if (!exchangeDetails) { - throw Error(`exchange ${exchangeInfo.baseUrl} details not available`); - } - const exchangeWireInfo = exchangeInfo.wireInfo; - if (!exchangeWireInfo) { - throw Error( - `exchange ${exchangeInfo.baseUrl} wire details not available`, - ); - } - - const selectedDenoms = await this.getVerifiedWithdrawDenomList( - baseUrl, - amount, - ); - let acc = Amounts.getZero(amount.currency); - for (const d of selectedDenoms) { - acc = Amounts.add(acc, d.feeWithdraw).amount; - } - const actualCoinCost = selectedDenoms - .map( - (d: DenominationRecord) => Amounts.add(d.value, d.feeWithdraw).amount, - ) - .reduce((a, b) => Amounts.add(a, b).amount); - - const exchangeWireAccounts: string[] = []; - for (let account of exchangeWireInfo.accounts) { - exchangeWireAccounts.push(account.url); - } - - const { isTrusted, isAudited } = await this.getExchangeTrust(exchangeInfo); - - let earliestDepositExpiration = selectedDenoms[0].stampExpireDeposit; - for (let i = 1; i < selectedDenoms.length; i++) { - const expireDeposit = selectedDenoms[i].stampExpireDeposit; - if (expireDeposit.t_ms < earliestDepositExpiration.t_ms) { - earliestDepositExpiration = expireDeposit; - } - } - - const possibleDenoms = await oneShotIterIndex( - this.db, - Stores.denominations.exchangeBaseUrlIndex, - baseUrl, - ).filter(d => d.isOffered); - - const trustedAuditorPubs = []; - const currencyRecord = await oneShotGet( - this.db, - Stores.currencies, - amount.currency, + return getWithdrawDetailsForUri( + this.ws, + talerWithdrawUri, + maybeSelectedExchange, ); - if (currencyRecord) { - trustedAuditorPubs.push( - ...currencyRecord.auditors.map(a => a.auditorPub), - ); - } - - let versionMatch; - if (exchangeDetails.protocolVersion) { - versionMatch = LibtoolVersion.compare( - WALLET_PROTOCOL_VERSION, - exchangeDetails.protocolVersion, - ); - - if ( - versionMatch && - !versionMatch.compatible && - versionMatch.currentCmp === -1 - ) { - console.warn( - `wallet version ${WALLET_PROTOCOL_VERSION} might be outdated ` + - `(exchange has ${exchangeDetails.protocolVersion}), checking for updates`, - ); - if (isFirefox()) { - console.log("skipping update check on Firefox"); - } else { - chrome.runtime.requestUpdateCheck((status, details) => { - console.log("update check status:", status); - }); - } - } - } - - const ret: ReserveCreationInfo = { - earliestDepositExpiration, - exchangeInfo, - exchangeWireAccounts, - exchangeVersion: exchangeDetails.protocolVersion || "unknown", - isAudited, - isTrusted, - numOfferedDenoms: possibleDenoms.length, - overhead: Amounts.sub(amount, actualCoinCost).amount, - selectedDenoms, - trustedAuditorPubs, - versionMatch, - walletVersion: WALLET_PROTOCOL_VERSION, - wireFees: exchangeWireInfo, - withdrawFee: acc, - }; - return ret; - } - - async getExchangePaytoUri( - exchangeBaseUrl: string, - supportedTargetTypes: string[], - ): Promise<string> { - // 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.`); - } - const exchangeWireInfo = exchangeRecord.wireInfo; - if (!exchangeWireInfo) { - throw Error(`Exchange wire info for '${exchangeBaseUrl}' not found.`); - } - for (let account of exchangeWireInfo.accounts) { - const paytoUri = new URI(account.url); - if (supportedTargetTypes.includes(paytoUri.authority())) { - return account.url; - } - } - throw Error("no matching exchange account found"); } /** @@ -2264,716 +421,22 @@ export class Wallet { baseUrl: string, force: boolean = false, ): Promise<ExchangeRecord> { - const now = getTimestampNow(); - baseUrl = canonicalizeBaseUrl(baseUrl); - - const r = await oneShotGet(this.db, Stores.exchanges, baseUrl); - if (!r) { - const newExchangeRecord: ExchangeRecord = { - baseUrl: baseUrl, - details: undefined, - wireInfo: undefined, - updateStatus: ExchangeUpdateStatus.FETCH_KEYS, - updateStarted: now, - updateReason: "initial", - timestampAdded: getTimestampNow(), - }; - await oneShotPut(this.db, Stores.exchanges, newExchangeRecord); - } else { - await runWithWriteTransaction(this.db, [Stores.exchanges], async t => { - const rec = await t.get(Stores.exchanges, baseUrl); - if (!rec) { - return; - } - 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); - }); - } - - await this.updateExchangeWithKeys(baseUrl); - await this.updateExchangeWithWireInfo(baseUrl); - - const updatedExchange = await oneShotGet( - this.db, - Stores.exchanges, - baseUrl, - ); - - if (!updatedExchange) { - // This should practically never happen - throw Error("exchange not found"); - } - return updatedExchange; - } - - private async setExchangeError( - baseUrl: string, - err: OperationError, - ): Promise<void> { - const mut = (exchange: ExchangeRecord) => { - exchange.lastError = err; - return exchange; - }; - await oneShotMutate(this.db, Stores.exchanges, baseUrl, mut); - } - - private async setReserveError( - reservePub: string, - err: OperationError, - ): Promise<void> { - 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. - * - * Exceptions thrown in this method must be caught and reported - * in the pending operations. - */ - private async updateExchangeWithKeys(baseUrl: string): Promise<void> { - const existingExchangeRecord = await oneShotGet( - this.db, - Stores.exchanges, - baseUrl, - ); - - if ( - existingExchangeRecord?.updateStatus != ExchangeUpdateStatus.FETCH_KEYS - ) { - return; - } - const keysUrl = new URI("keys") - .absoluteTo(baseUrl) - .addQuery("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION); - let keysResp; - try { - keysResp = await this.http.get(keysUrl.href()); - } catch (e) { - const m = `Fetching keys failed: ${e.message}`; - await this.setExchangeError(baseUrl, { - type: "network", - details: { - requestUrl: e.config?.url, - }, - message: m, - }); - 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: m, - }); - throw new OperationFailedAndReportedError(m); - } - - const lastUpdateTimestamp = extractTalerStamp( - exchangeKeysJson.list_issue_date, - ); - if (!lastUpdateTimestamp) { - const m = `Parsing /keys response failed: invalid list_issue_date.`; - await this.setExchangeError(baseUrl, { - type: "protocol-violation", - details: {}, - message: m, - }); - throw new OperationFailedAndReportedError(m); - } - - if (exchangeKeysJson.denoms.length === 0) { - const m = "exchange doesn't offer any denominations"; - await this.setExchangeError(baseUrl, { - type: "protocol-violation", - details: {}, - message: m, - }); - throw new OperationFailedAndReportedError(m); - } - - const protocolVersion = exchangeKeysJson.version; - if (!protocolVersion) { - const m = "outdate exchange, no version in /keys response"; - await this.setExchangeError(baseUrl, { - type: "protocol-violation", - details: {}, - message: m, - }); - throw new OperationFailedAndReportedError(m); - } - - const currency = Amounts.parseOrThrow(exchangeKeysJson.denoms[0].value) - .currency; - - 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) { - 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); - const resp = await this.http.get(reqUrl.href()); - - const wiJson = resp.responseJson; - if (!wiJson) { - 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); - }); + return updateExchangeFromUrl(this.ws, baseUrl, force); } /** * Get detailed balance information, sliced by exchange and by currency. */ async getBalances(): Promise<WalletBalance> { - /** - * Add amount to a balance field, both for - * the slicing by exchange and currency. - */ - function addTo( - balance: WalletBalance, - field: keyof WalletBalanceEntry, - amount: AmountJson, - exchange: string, - ): void { - const z = Amounts.getZero(amount.currency); - const balanceIdentity = { - available: z, - paybackAmount: z, - pendingIncoming: z, - pendingPayment: z, - pendingIncomingDirty: z, - pendingIncomingRefresh: z, - pendingIncomingWithdraw: z, - }; - let entryCurr = balance.byCurrency[amount.currency]; - if (!entryCurr) { - balance.byCurrency[amount.currency] = entryCurr = { - ...balanceIdentity, - }; - } - let entryEx = balance.byExchange[exchange]; - if (!entryEx) { - balance.byExchange[exchange] = entryEx = { ...balanceIdentity }; - } - entryCurr[field] = Amounts.add(entryCurr[field], amount).amount; - entryEx[field] = Amounts.add(entryEx[field], amount).amount; - } - - const balanceStore = { - byCurrency: {}, - byExchange: {}, - }; - - await runWithWriteTransaction( - this.db, - [Stores.coins, Stores.refresh, Stores.reserves, Stores.purchases], - async tx => { - await tx.iter(Stores.coins).forEach(c => { - if (c.suspended) { - return; - } - if (c.status === CoinStatus.Fresh) { - addTo( - balanceStore, - "available", - c.currentAmount, - c.exchangeBaseUrl, - ); - } - if (c.status === CoinStatus.Dirty) { - addTo( - balanceStore, - "pendingIncoming", - c.currentAmount, - c.exchangeBaseUrl, - ); - addTo( - balanceStore, - "pendingIncomingDirty", - c.currentAmount, - c.exchangeBaseUrl, - ); - } - }); - await tx.iter(Stores.refresh).forEach(r => { - // Don't count finished refreshes, since the refresh already resulted - // in coins being added to the wallet. - if (r.finished) { - return; - } - addTo( - balanceStore, - "pendingIncoming", - r.valueOutput, - r.exchangeBaseUrl, - ); - addTo( - balanceStore, - "pendingIncomingRefresh", - r.valueOutput, - r.exchangeBaseUrl, - ); - }); - - await tx.iter(Stores.purchases).forEach(t => { - if (t.finished) { - return; - } - for (const c of t.payReq.coins) { - addTo( - balanceStore, - "pendingPayment", - Amounts.parseOrThrow(c.contribution), - c.exchange_url, - ); - } - }); - }, - ); - - Wallet.enableTracing && console.log("computed balances:", balanceStore); - return balanceStore; + return getBalances(this.ws); } async refresh(oldCoinPub: string, force: boolean = false): Promise<void> { - const coin = await oneShotGet(this.db, Stores.coins, oldCoinPub); - if (!coin) { - console.warn("can't refresh, coin not in database"); - return; - } - 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: exchange of coin not found"); - } - - const oldDenom = await oneShotGet(this.db, Stores.denominations, [ - exchange.baseUrl, - coin.denomPub, - ]); - - if (!oldDenom) { - throw Error("db inconsistent: denomination for coin not found"); - } - - const availableDenoms: DenominationRecord[] = await oneShotIterIndex( - this.db, - Stores.denominations.exchangeBaseUrlIndex, - exchange.baseUrl, - ).toArray(); - - const availableAmount = Amounts.sub(coin.currentAmount, oldDenom.feeRefresh) - .amount; - - const newCoinDenoms = getWithdrawDenomList( - availableAmount, - availableDenoms, - ); - - if (newCoinDenoms.length === 0) { - 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; - } - - const refreshSession: RefreshSessionRecord = await this.cryptoApi.createRefreshSession( - exchange.baseUrl, - 3, - coin, - newCoinDenoms, - oldDenom.feeRefresh, - ); - - function mutateCoin(c: CoinRecord): CoinRecord { - const r = Amounts.sub(c.currentAmount, refreshSession.valueWithFee); - if (r.saturated) { - // Something else must have written the coin value - throw TransactionAbort; - } - c.currentAmount = r.amount; - c.status = CoinStatus.Dormant; - return c; - } - - // Store refresh session and subtract refreshed amount from - // coin in the same transaction. - await runWithWriteTransaction( - this.db, - [Stores.refresh, Stores.coins], - async tx => { - await tx.put(Stores.refresh, refreshSession); - await tx.mutate(Stores.coins, coin.coinPub, mutateCoin); - }, - ); - logger.info(`created refresh session ${refreshSession.refreshSessionId}`); - this.notifier.notify(); - - await this.processRefreshSession(refreshSession.refreshSessionId); + return refresh(this.ws, oldCoinPub, force); } async processRefreshSession(refreshSessionId: string) { - const refreshSession = await oneShotGet( - this.db, - Stores.refresh, - refreshSessionId, - ); - if (!refreshSession) { - return; - } - if (refreshSession.finished) { - return; - } - if (typeof refreshSession.norevealIndex !== "number") { - await this.refreshMelt(refreshSession.refreshSessionId); - } - await this.refreshReveal(refreshSession.refreshSessionId); - logger.trace("refresh finished"); - } - - async refreshMelt(refreshSessionId: string): Promise<void> { - const refreshSession = await oneShotGet( - this.db, - Stores.refresh, - refreshSessionId, - ); - if (!refreshSession) { - return; - } - if (refreshSession.norevealIndex !== undefined) { - return; - } - - const coin = await oneShotGet( - this.db, - Stores.coins, - refreshSession.meltCoinPub, - ); - - if (!coin) { - console.error("can't melt coin, it does not exist"); - return; - } - - const reqUrl = new URI("refresh/melt").absoluteTo( - refreshSession.exchangeBaseUrl, - ); - const meltReq = { - coin_pub: coin.coinPub, - confirm_sig: refreshSession.confirmSig, - denom_pub_hash: coin.denomPubHash, - denom_sig: coin.denomSig, - rc: refreshSession.hash, - value_with_fee: refreshSession.valueWithFee, - }; - Wallet.enableTracing && console.log("melt request:", meltReq); - const resp = await this.http.postJson(reqUrl.href(), meltReq); - - Wallet.enableTracing && console.log("melt response:", resp.responseJson); - - if (resp.status !== 200) { - console.error(resp.responseJson); - throw Error("refresh failed"); - } - - const respJson = resp.responseJson; - - const norevealIndex = respJson.noreveal_index; - - if (typeof norevealIndex !== "number") { - throw Error("invalid response"); - } - - refreshSession.norevealIndex = norevealIndex; - - 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(); - } - - private async refreshReveal(refreshSessionId: string): Promise<void> { - 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"); - } - const privs = Array.from(refreshSession.transferPrivs); - privs.splice(norevealIndex, 1); - - const planchets = refreshSession.planchetsForGammas[norevealIndex]; - if (!planchets) { - throw Error("refresh index error"); - } - - const meltCoinRecord = await oneShotGet( - this.db, - Stores.coins, - refreshSession.meltCoinPub, - ); - if (!meltCoinRecord) { - throw Error("inconsistent database"); - } - - const evs = planchets.map((x: RefreshPlanchetRecord) => x.coinEv); - - const linkSigs: string[] = []; - for (let i = 0; i < refreshSession.newDenoms.length; i++) { - const linkSig = await this.cryptoApi.signCoinLink( - meltCoinRecord.coinPriv, - refreshSession.newDenomHashes[i], - refreshSession.meltCoinPub, - refreshSession.transferPubs[norevealIndex], - planchets[i].coinEv, - ); - linkSigs.push(linkSig); - } - - const req = { - coin_evs: evs, - new_denoms_h: refreshSession.newDenomHashes, - rc: refreshSession.hash, - transfer_privs: privs, - transfer_pub: refreshSession.transferPubs[norevealIndex], - link_sigs: linkSigs, - }; - - const reqUrl = new URI("refresh/reveal").absoluteTo( - refreshSession.exchangeBaseUrl, - ); - Wallet.enableTracing && console.log("reveal request:", req); - - let resp; - try { - resp = await this.http.postJson(reqUrl.href(), req); - } catch (e) { - console.error("got error during /refresh/reveal request"); - console.error(e); - return; - } - - Wallet.enableTracing && console.log("session:", refreshSession); - Wallet.enableTracing && console.log("reveal response:", resp); - - if (resp.status !== 200) { - console.error("error: /refresh/reveal returned status " + resp.status); - return; - } - - const respJson = resp.responseJson; - - if (!respJson.ev_sigs || !Array.isArray(respJson.ev_sigs)) { - console.error("/refresh/reveal did not contain ev_sigs"); - return; - } - - const exchange = await this.findExchange(refreshSession.exchangeBaseUrl); - if (!exchange) { - console.error(`exchange ${refreshSession.exchangeBaseUrl} not found`); - return; - } - - const coins: CoinRecord[] = []; - - for (let i = 0; i < respJson.ev_sigs.length; i++) { - const denom = await oneShotGet(this.db, Stores.denominations, [ - refreshSession.exchangeBaseUrl, - refreshSession.newDenoms[i], - ]); - if (!denom) { - console.error("denom not found"); - continue; - } - const pc = - refreshSession.planchetsForGammas[refreshSession.norevealIndex!][i]; - const denomSig = await this.cryptoApi.rsaUnblind( - respJson.ev_sigs[i].ev_sig, - pc.blindingKey, - denom.denomPub, - ); - const coin: CoinRecord = { - blindingKey: pc.blindingKey, - coinPriv: pc.privateKey, - coinPub: pc.publicKey, - currentAmount: denom.value, - denomPub: denom.denomPub, - denomPubHash: denom.denomPubHash, - denomSig, - exchangeBaseUrl: refreshSession.exchangeBaseUrl, - reservePub: undefined, - status: CoinStatus.Fresh, - coinIndex: -1, - withdrawSessionId: "", - }; - - coins.push(coin); - } - - refreshSession.finished = true; - - await runWithWriteTransaction( - 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); - } - await tx.put(Stores.refresh, refreshSession); - }, - ); - this.notifier.notify(); + return processRefreshSession(this.ws, refreshSessionId); } async findExchange( @@ -2988,324 +451,11 @@ export class Wallet { async getHistory( historyQuery?: HistoryQuery, ): Promise<{ history: HistoryEvent[] }> { - const history: HistoryEvent[] = []; - - // FIXME: do pagination instead of generating the full history - - // We uniquely identify history rows via their timestamp. - // This works as timestamps are guaranteed to be monotonically - // increasing even - - const proposals = await oneShotIter(this.db, Stores.proposals).toArray(); - for (const p of proposals) { - history.push({ - detail: { - contractTermsHash: p.contractTermsHash, - merchantName: p.contractTerms.merchant.name, - }, - timestamp: p.timestamp, - type: "claim-order", - explicit: false, - }); - } - - const withdrawals = await oneShotIter( - this.db, - Stores.withdrawalSession, - ).toArray(); - for (const w of withdrawals) { - history.push({ - detail: { - withdrawalAmount: w.withdrawalAmount, - }, - timestamp: w.startTimestamp, - type: "withdraw", - explicit: false, - }); - } - - const purchases = await oneShotIter(this.db, Stores.purchases).toArray(); - for (const p of purchases) { - history.push({ - detail: { - amount: p.contractTerms.amount, - contractTermsHash: p.contractTermsHash, - fulfillmentUrl: p.contractTerms.fulfillment_url, - merchantName: p.contractTerms.merchant.name, - }, - timestamp: p.timestamp, - type: "pay", - explicit: false, - }); - if (p.timestamp_refund) { - const contractAmount = Amounts.parseOrThrow(p.contractTerms.amount); - const amountsPending = Object.keys(p.refundsPending).map(x => - Amounts.parseOrThrow(p.refundsPending[x].refund_amount), - ); - const amountsDone = Object.keys(p.refundsDone).map(x => - Amounts.parseOrThrow(p.refundsDone[x].refund_amount), - ); - const amounts: AmountJson[] = amountsPending.concat(amountsDone); - const amount = Amounts.add( - Amounts.getZero(contractAmount.currency), - ...amounts, - ).amount; - - history.push({ - detail: { - contractTermsHash: p.contractTermsHash, - fulfillmentUrl: p.contractTerms.fulfillment_url, - merchantName: p.contractTerms.merchant.name, - refundAmount: amount, - }, - timestamp: p.timestamp_refund, - type: "refund", - explicit: false, - }); - } - } - - 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.exchangeBaseUrl, - requestedAmount: Amounts.toString(r.initiallyRequestedAmount), - reservePub: r.reservePub, - reserveType, - bankWithdrawStatusUrl: r.bankWithdrawStatusUrl, - }, - timestamp: r.created, - type: "reserve-created", - explicit: false, - }); - if (r.timestampConfirmed) { - history.push({ - detail: { - exchangeBaseUrl: r.exchangeBaseUrl, - requestedAmount: Amounts.toString(r.initiallyRequestedAmount), - reservePub: r.reservePub, - reserveType, - bankWithdrawStatusUrl: r.bankWithdrawStatusUrl, - }, - timestamp: r.created, - type: "reserve-confirmed", - explicit: false, - }); - } - } - - const tips: TipRecord[] = await oneShotIter(this.db, Stores.tips).toArray(); - for (const tip of tips) { - history.push({ - detail: { - accepted: tip.accepted, - amount: tip.amount, - merchantDomain: tip.merchantDomain, - tipId: tip.tipId, - }, - timestamp: tip.timestamp, - explicit: false, - type: "tip", - }); - } - - 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 }; + return getHistory(this.ws, historyQuery); } async getPendingOperations(): Promise<PendingOperationsResponse> { - const pendingOperations: PendingOperationInfo[] = []; - const exchanges = await this.getExchanges(); - for (let e of exchanges) { - switch (e.updateStatus) { - 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", - message: - "Exchange record does not have details, but no update in progress.", - details: { - exchangeBaseUrl: e.baseUrl, - }, - }); - } - 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: - pendingOperations.push({ - 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: - 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: - 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, - refreshSessionId: r.refreshSessionId, - }); - }); - - await oneShotIter(this.db, Stores.planchets).forEach(pc => { - pendingOperations.push({ - 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, - }; + return getPendingOperations(this.ws); } async getDenoms(exchangeUrl: string): Promise<DenominationRecord[]> { @@ -3331,7 +481,7 @@ export class Wallet { } async updateCurrency(currencyRecord: CurrencyRecord): Promise<void> { - Wallet.enableTracing && console.log("updating currency to", currencyRecord); + logger.trace("updating currency to", currencyRecord); await oneShotPut(this.db, Stores.currencies, currencyRecord); this.notifier.notify(); } @@ -3352,107 +502,8 @@ export class Wallet { return await oneShotIter(this.db, Stores.coins).toArray(); } - async getPlanchets(exchangeBaseUrl: string): Promise<PlanchetRecord[]> { - return await oneShotIter(this.db, Stores.planchets).filter( - c => c.exchangeBaseUrl === exchangeBaseUrl, - ); - } - - private async hashContract(contract: ContractTerms): Promise<string> { - return this.cryptoApi.hashString(canonicalJson(contract)); - } - async payback(coinPub: string): Promise<void> { - let coin = await oneShotGet(this.db, Stores.coins, coinPub); - if (!coin) { - throw Error(`Coin ${coinPub} not found, can't request payback`); - } - const reservePub = coin.reservePub; - if (!reservePub) { - throw Error(`Can't request payback for a refreshed coin`); - } - const reserve = await oneShotGet(this.db, Stores.reserves, reservePub); - if (!reserve) { - throw Error(`Reserve of coin ${coinPub} not found`); - } - switch (coin.status) { - case CoinStatus.Dormant: - throw Error(`Can't do payback for coin ${coinPub} since it's dormant`); - } - 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. - reserve.hasPayback = true; - await runWithWriteTransaction( - this.db, - [Stores.coins, Stores.reserves], - async tx => { - await tx.put(Stores.coins, coin!!); - await tx.put(Stores.reserves, reserve); - }, - ); - this.notifier.notify(); - - const paybackRequest = await this.cryptoApi.createPaybackRequest(coin); - const reqUrl = new URI("payback").absoluteTo(coin.exchangeBaseUrl); - const resp = await this.http.postJson(reqUrl.href(), paybackRequest); - if (resp.status !== 200) { - throw Error(); - } - const paybackConfirmation = PaybackConfirmation.checked(resp.responseJson); - if (paybackConfirmation.reserve_pub !== coin.reservePub) { - throw Error(`Coin's reserve doesn't match reserve on payback`); - } - coin = await oneShotGet(this.db, Stores.coins, coinPub); - if (!coin) { - throw Error(`Coin ${coinPub} not found, can't confirm payback`); - } - coin.status = CoinStatus.Dormant; - await oneShotPut(this.db, Stores.coins, coin); - this.notifier.notify(); - await this.updateReserve(reservePub!); - } - - private async denominationRecordFromKeys( - exchangeBaseUrl: string, - denomIn: Denomination, - ): Promise<DenominationRecord> { - const denomPubHash = await this.cryptoApi.hashDenomPub(denomIn.denom_pub); - const d: DenominationRecord = { - denomPub: denomIn.denom_pub, - denomPubHash, - exchangeBaseUrl, - feeDeposit: Amounts.parseOrThrow(denomIn.fee_deposit), - feeRefresh: Amounts.parseOrThrow(denomIn.fee_refresh), - feeRefund: Amounts.parseOrThrow(denomIn.fee_refund), - feeWithdraw: Amounts.parseOrThrow(denomIn.fee_withdraw), - isOffered: true, - masterSig: denomIn.master_sig, - stampExpireDeposit: extractTalerStampOrThrow( - denomIn.stamp_expire_deposit, - ), - stampExpireLegal: extractTalerStampOrThrow(denomIn.stamp_expire_legal), - stampExpireWithdraw: extractTalerStampOrThrow( - denomIn.stamp_expire_withdraw, - ), - stampStart: extractTalerStampOrThrow(denomIn.stamp_start), - status: DenominationStatus.Unverified, - value: Amounts.parseOrThrow(denomIn.value), - }; - return d; - } - - async withdrawPaybackReserve(reservePub: string): Promise<void> { - const reserve = await oneShotGet(this.db, Stores.reserves, reservePub); - if (!reserve) { - throw Error(`Reserve ${reservePub} does not exist`); - } - reserve.hasPayback = false; - await oneShotPut(this.db, Stores.reserves, reserve); - this.depleteReserve(reserve.reservePub).catch(e => { - console.error("Error depleting reserve after payback", e); - }); + return payback(this.ws, coinPub); } async getPaybackReserves(): Promise<ReserveRecord[]> { @@ -3481,7 +532,6 @@ export class Wallet { Object.keys(wi.feesForType).map(k => s.add(k)); }); - Wallet.enableTracing && console.log(m); const exchangeWireTypes: { [url: string]: string[] } = {}; Object.keys(m).map(e => { exchangeWireTypes[e] = Array.from(m[e]); @@ -3504,202 +554,7 @@ export class Wallet { * Trigger paying coins back into the user's account. */ async returnCoins(req: ReturnCoinsRequest): Promise<void> { - Wallet.enableTracing && console.log("got returnCoins request", req); - const wireType = (req.senderWire as any).type; - Wallet.enableTracing && console.log("wireType", wireType); - if (!wireType || typeof wireType !== "string") { - console.error(`wire type must be a non-empty string, not ${wireType}`); - return; - } - const stampSecNow = Math.floor(new Date().getTime() / 1000); - const exchange = await this.findExchange(req.exchange); - if (!exchange) { - console.error(`Exchange ${req.exchange} not known to the wallet`); - return; - } - const exchangeDetails = exchange.details; - if (!exchangeDetails) { - throw Error("exchange information needs to be updated first."); - } - Wallet.enableTracing && console.log("selecting coins for return:", req); - const cds = await this.getCoinsForReturn(req.exchange, req.amount); - Wallet.enableTracing && console.log(cds); - - if (!cds) { - throw Error("coin return impossible, can't select coins"); - } - - const { priv, pub } = await this.cryptoApi.createEddsaKeypair(); - - const wireHash = await this.cryptoApi.hashString( - canonicalJson(req.senderWire), - ); - - const contractTerms: ContractTerms = { - H_wire: wireHash, - amount: Amounts.toString(req.amount), - auditors: [], - exchanges: [ - { master_pub: exchangeDetails.masterPublicKey, url: exchange.baseUrl }, - ], - extra: {}, - fulfillment_url: "", - locations: [], - max_fee: Amounts.toString(req.amount), - merchant: {}, - merchant_pub: pub, - order_id: "none", - pay_deadline: `/Date(${stampSecNow + 30 * 5})/`, - wire_transfer_deadline: `/Date(${stampSecNow + 60 * 5})/`, - merchant_base_url: "taler://return-to-account", - products: [], - refund_deadline: `/Date(${stampSecNow + 60 * 5})/`, - timestamp: `/Date(${stampSecNow})/`, - wire_method: wireType, - }; - - const contractTermsHash = await this.cryptoApi.hashString( - canonicalJson(contractTerms), - ); - - const payCoinInfo = await this.cryptoApi.signDeposit( - contractTerms, - cds, - Amounts.parseOrThrow(contractTerms.amount), - ); - - Wallet.enableTracing && console.log("pci", payCoinInfo); - - const coins = payCoinInfo.sigs.map(s => ({ coinPaySig: s })); - - const coinsReturnRecord: CoinsReturnRecord = { - coins, - contractTerms, - contractTermsHash, - exchange: exchange.baseUrl, - merchantPriv: priv, - wire: req.senderWire, - }; - - await runWithWriteTransaction( - this.db, - [Stores.coinsReturns, Stores.coins], - async tx => { - await tx.put(Stores.coinsReturns, coinsReturnRecord); - for (let c of payCoinInfo.updatedCoins) { - await tx.put(Stores.coins, c); - } - }, - ); - this.badge.showNotification(); - this.notifier.notify(); - - this.depositReturnedCoins(coinsReturnRecord); - } - - async depositReturnedCoins( - coinsReturnRecord: CoinsReturnRecord, - ): Promise<void> { - for (const c of coinsReturnRecord.coins) { - if (c.depositedSig) { - continue; - } - const req = { - H_wire: coinsReturnRecord.contractTerms.H_wire, - coin_pub: c.coinPaySig.coin_pub, - coin_sig: c.coinPaySig.coin_sig, - contribution: c.coinPaySig.contribution, - denom_pub: c.coinPaySig.denom_pub, - h_contract_terms: coinsReturnRecord.contractTermsHash, - merchant_pub: coinsReturnRecord.contractTerms.merchant_pub, - pay_deadline: coinsReturnRecord.contractTerms.pay_deadline, - refund_deadline: coinsReturnRecord.contractTerms.refund_deadline, - timestamp: coinsReturnRecord.contractTerms.timestamp, - ub_sig: c.coinPaySig.ub_sig, - wire: coinsReturnRecord.wire, - wire_transfer_deadline: coinsReturnRecord.contractTerms.pay_deadline, - }; - Wallet.enableTracing && console.log("req", req); - const reqUrl = new URI("deposit").absoluteTo(coinsReturnRecord.exchange); - const resp = await this.http.postJson(reqUrl.href(), req); - if (resp.status !== 200) { - console.error("deposit failed due to status code", resp); - continue; - } - const respJson = resp.responseJson; - if (respJson.status !== "DEPOSIT_OK") { - console.error("deposit failed", resp); - continue; - } - - if (!respJson.sig) { - console.error("invalid 'sig' field", resp); - continue; - } - - // FIXME: verify signature - - // For every successful deposit, we replace the old record with an updated one - const currentCrr = await oneShotGet( - this.db, - Stores.coinsReturns, - coinsReturnRecord.contractTermsHash, - ); - if (!currentCrr) { - console.error("database inconsistent"); - continue; - } - for (const nc of currentCrr.coins) { - if (nc.coinPaySig.coin_pub === c.coinPaySig.coin_pub) { - nc.depositedSig = respJson.sig; - } - } - await oneShotPut(this.db, Stores.coinsReturns, currentCrr); - this.notifier.notify(); - } - } - - private async acceptRefundResponse( - refundResponse: MerchantRefundResponse, - ): Promise<string> { - const refundPermissions = refundResponse.refund_permissions; - - if (!refundPermissions.length) { - console.warn("got empty refund list"); - throw Error("empty refund"); - } - - /** - * Add refund to purchase if not already added. - */ - function f(t: PurchaseRecord | undefined): PurchaseRecord | undefined { - if (!t) { - console.error("purchase not found, not adding refunds"); - return; - } - - t.timestamp_refund = getTimestampNow(); - - for (const perm of refundPermissions) { - if ( - !t.refundsPending[perm.merchant_sig] && - !t.refundsDone[perm.merchant_sig] - ) { - t.refundsPending[perm.merchant_sig] = perm; - } - } - return t; - } - - const hc = refundResponse.h_contract_terms; - - // Add the refund permissions to the purchase within a DB transaction - await oneShotMutate(this.db, Stores.purchases, hc, f); - this.notifier.notify(); - - await this.submitRefunds(hc); - - return hc; + return returnCoins(this.ws, req); } /** @@ -3707,112 +562,7 @@ export class Wallet { * that was involved in the refund. */ async applyRefund(talerRefundUri: string): Promise<string> { - const parseResult = parseRefundUri(talerRefundUri); - - if (!parseResult) { - throw Error("invalid refund URI"); - } - - const refundUrl = parseResult.refundUrl; - - Wallet.enableTracing && console.log("processing refund"); - let resp; - try { - resp = await this.http.get(refundUrl); - } catch (e) { - console.error("error downloading refund permission", e); - throw e; - } - - const refundResponse = MerchantRefundResponse.checked(resp.responseJson); - return this.acceptRefundResponse(refundResponse); - } - - private async submitRefunds(contractTermsHash: string): Promise<void> { - const purchase = await oneShotGet( - this.db, - Stores.purchases, - contractTermsHash, - ); - if (!purchase) { - console.error( - "not submitting refunds, contract terms not found:", - contractTermsHash, - ); - return; - } - const pendingKeys = Object.keys(purchase.refundsPending); - if (pendingKeys.length === 0) { - return; - } - for (const pk of pendingKeys) { - const perm = purchase.refundsPending[pk]; - const req: RefundRequest = { - coin_pub: perm.coin_pub, - h_contract_terms: purchase.contractTermsHash, - merchant_pub: purchase.contractTerms.merchant_pub, - merchant_sig: perm.merchant_sig, - refund_amount: perm.refund_amount, - refund_fee: perm.refund_fee, - rtransaction_id: perm.rtransaction_id, - }; - console.log("sending refund permission", perm); - // FIXME: not correct once we support multiple exchanges per payment - const exchangeUrl = purchase.payReq.coins[0].exchange_url; - const reqUrl = new URI("refund").absoluteTo(exchangeUrl); - const resp = await this.http.postJson(reqUrl.href(), req); - if (resp.status !== 200) { - console.error("refund failed", resp); - continue; - } - - // Transactionally mark successful refunds as done - const transformPurchase = ( - t: PurchaseRecord | undefined, - ): PurchaseRecord | undefined => { - if (!t) { - console.warn("purchase not found, not updating refund"); - return; - } - if (t.refundsPending[pk]) { - t.refundsDone[pk] = t.refundsPending[pk]; - delete t.refundsPending[pk]; - } - return t; - }; - const transformCoin = ( - c: CoinRecord | undefined, - ): CoinRecord | undefined => { - if (!c) { - console.warn("coin not found, can't apply refund"); - return; - } - const refundAmount = Amounts.parseOrThrow(perm.refund_amount); - const refundFee = Amounts.parseOrThrow(perm.refund_fee); - c.status = CoinStatus.Dirty; - c.currentAmount = Amounts.add(c.currentAmount, refundAmount).amount; - c.currentAmount = Amounts.sub(c.currentAmount, refundFee).amount; - - return c; - }; - - await runWithWriteTransaction( - this.db, - [Stores.purchases, Stores.coins], - async tx => { - await tx.mutate( - Stores.purchases, - contractTermsHash, - transformPurchase, - ); - await tx.mutate(Stores.coins, perm.coin_pub, transformCoin); - }, - ); - this.refresh(perm.coin_pub); - } - - this.badge.showNotification(); - this.notifier.notify(); + return applyRefund(this.ws, talerRefundUri); } async getPurchase( @@ -3824,277 +574,19 @@ export class Wallet { async getFullRefundFees( refundPermissions: MerchantRefundPermission[], ): Promise<AmountJson> { - if (refundPermissions.length === 0) { - throw Error("no refunds given"); - } - const coin0 = await oneShotGet( - this.db, - Stores.coins, - refundPermissions[0].coin_pub, - ); - if (!coin0) { - throw Error("coin not found"); - } - let feeAcc = Amounts.getZero( - Amounts.parseOrThrow(refundPermissions[0].refund_amount).currency, - ); - - const denoms = await oneShotIterIndex( - this.db, - Stores.denominations.exchangeBaseUrlIndex, - coin0.exchangeBaseUrl, - ).toArray(); - - for (const rp of refundPermissions) { - const coin = await oneShotGet(this.db, Stores.coins, rp.coin_pub); - if (!coin) { - throw Error("coin not found"); - } - const denom = await oneShotGet(this.db, Stores.denominations, [ - coin0.exchangeBaseUrl, - coin.denomPub, - ]); - if (!denom) { - throw Error(`denom not found (${coin.denomPub})`); - } - // FIXME: this assumes that the refund already happened. - // When it hasn't, the refresh cost is inaccurate. To fix this, - // we need introduce a flag to tell if a coin was refunded or - // refreshed normally (and what about incremental refunds?) - const refundAmount = Amounts.parseOrThrow(rp.refund_amount); - const refundFee = Amounts.parseOrThrow(rp.refund_fee); - const refreshCost = getTotalRefreshCost( - denoms, - denom, - Amounts.sub(refundAmount, refundFee).amount, - ); - feeAcc = Amounts.add(feeAcc, refreshCost, refundFee).amount; - } - return feeAcc; + return getFullRefundFees(this.ws, refundPermissions); } async acceptTip(talerTipUri: string): Promise<void> { - const { tipId, merchantOrigin } = await this.getTipStatus(talerTipUri); - let tipRecord = await oneShotGet(this.db, Stores.tips, [ - tipId, - merchantOrigin, - ]); - if (!tipRecord) { - throw Error("tip not in database"); - } - - tipRecord.accepted = true; - await oneShotPut(this.db, Stores.tips, tipRecord); - - if (tipRecord.pickedUp) { - console.log("tip already picked up"); - return; - } - await this.updateExchangeFromUrl(tipRecord.exchangeUrl); - const denomsForWithdraw = await this.getVerifiedWithdrawDenomList( - tipRecord.exchangeUrl, - tipRecord.amount, - ); - - if (!tipRecord.planchets) { - const planchets = await Promise.all( - denomsForWithdraw.map(d => this.cryptoApi.createTipPlanchet(d)), - ); - const coinPubs: string[] = planchets.map(x => x.coinPub); - - await oneShotMutate(this.db, Stores.tips, [tipId, merchantOrigin], r => { - if (!r.planchets) { - r.planchets = planchets; - r.coinPubs = coinPubs; - } - return r; - }); - - this.notifier.notify(); - } - - tipRecord = await oneShotGet(this.db, Stores.tips, [tipId, merchantOrigin]); - if (!tipRecord) { - throw Error("tip not in database"); - } - - if (!tipRecord.planchets) { - throw Error("invariant violated"); - } - - console.log("got planchets for tip!"); - - // Planchets in the form that the merchant expects - const planchetsDetail: TipPlanchetDetail[] = tipRecord.planchets.map(p => ({ - coin_ev: p.coinEv, - denom_pub_hash: p.denomPubHash, - })); - - let merchantResp; - - try { - const req = { planchets: planchetsDetail, tip_id: tipId }; - merchantResp = await this.http.postJson(tipRecord.pickupUrl, req); - console.log("got merchant resp:", merchantResp); - } catch (e) { - console.log("tipping failed", e); - throw e; - } - - const response = TipResponse.checked(merchantResp.responseJson); - - if (response.reserve_sigs.length !== tipRecord.planchets.length) { - throw Error("number of tip responses does not match requested planchets"); - } - - for (let i = 0; i < tipRecord.planchets.length; i++) { - 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.planchets, planchet); - await this.processPlanchet(planchet.coinPub); - } - - tipRecord.pickedUp = true; - - await oneShotPut(this.db, Stores.tips, tipRecord); - - this.notifier.notify(); - this.badge.showNotification(); - return; + return acceptTip(this.ws, talerTipUri); } async getTipStatus(talerTipUri: string): Promise<TipStatus> { - const res = parseTipUri(talerTipUri); - if (!res) { - throw Error("invalid taler://tip URI"); - } - - const tipStatusUrl = new URI(res.tipPickupUrl).href(); - console.log("checking tip status from", tipStatusUrl); - const merchantResp = await this.http.get(tipStatusUrl); - console.log("resp:", merchantResp.responseJson); - const tipPickupStatus = TipPickupGetResponse.checked( - merchantResp.responseJson, - ); - - console.log("status", tipPickupStatus); - - let amount = Amounts.parseOrThrow(tipPickupStatus.amount); - - let tipRecord = await oneShotGet(this.db, Stores.tips, [ - res.tipId, - res.merchantOrigin, - ]); - - if (!tipRecord) { - const withdrawDetails = await this.getWithdrawDetailsForAmount( - tipPickupStatus.exchange_url, - amount, - ); - - tipRecord = { - accepted: false, - amount, - coinPubs: [], - deadline: getTalerStampSec(tipPickupStatus.stamp_expire)!, - exchangeUrl: tipPickupStatus.exchange_url, - merchantDomain: res.merchantOrigin, - nextUrl: undefined, - pickedUp: false, - planchets: undefined, - response: undefined, - timestamp: getTimestampNow(), - tipId: res.tipId, - pickupUrl: res.tipPickupUrl, - totalFees: Amounts.add( - withdrawDetails.overhead, - withdrawDetails.withdrawFee, - ).amount, - }; - await oneShotPut(this.db, Stores.tips, tipRecord); - } - - const tipStatus: TipStatus = { - accepted: !!tipRecord && tipRecord.accepted, - amount: Amounts.parseOrThrow(tipPickupStatus.amount), - amountLeft: Amounts.parseOrThrow(tipPickupStatus.amount_left), - exchangeUrl: tipPickupStatus.exchange_url, - nextUrl: tipPickupStatus.extra.next_url, - merchantOrigin: res.merchantOrigin, - tipId: res.tipId, - expirationTimestamp: getTalerStampSec(tipPickupStatus.stamp_expire)!, - timestamp: getTalerStampSec(tipPickupStatus.stamp_created)!, - totalFees: tipRecord.totalFees, - }; - - return tipStatus; + return getTipStatus(this.ws, talerTipUri); } async abortFailedPayment(contractTermsHash: string): Promise<void> { - const purchase = await oneShotGet( - this.db, - Stores.purchases, - contractTermsHash, - ); - if (!purchase) { - throw Error("Purchase not found, unable to abort with refund"); - } - if (purchase.finished) { - throw Error("Purchase already finished, not aborting"); - } - if (purchase.abortDone) { - console.warn("abort requested on already aborted purchase"); - return; - } - - purchase.abortRequested = true; - - // From now on, we can't retry payment anymore, - // so mark this in the DB in case the /pay abort - // does not complete on the first try. - await oneShotPut(this.db, Stores.purchases, purchase); - - let resp; - - const abortReq = { ...purchase.payReq, mode: "abort-refund" }; - - const payUrl = new URI("pay") - .absoluteTo(purchase.contractTerms.merchant_base_url) - .href(); - - try { - resp = await this.http.postJson(payUrl, abortReq); - } catch (e) { - // Gives the user the option to retry / abort and refresh - console.log("aborting payment failed", e); - throw e; - } - - const refundResponse = MerchantRefundResponse.checked(resp.responseJson); - await this.acceptRefundResponse(refundResponse); - - await runWithWriteTransaction(this.db, [Stores.purchases], async tx => { - const p = await tx.get(Stores.purchases, purchase.contractTermsHash); - if (!p) { - return; - } - p.abortDone = true; - await tx.put(Stores.purchases, p); - }); + return abortFailedPayment(this.ws, contractTermsHash); } public async handleNotifyReserve() { @@ -4102,7 +594,7 @@ export class Wallet { for (const r of reserves) { if (r.reserveStatus === ReserveRecordStatus.WAIT_CONFIRM_BANK) { try { - this.processReserveBankStatus(r.reservePub); + this.processReserve(r.reservePub); } catch (e) { console.error(e); } @@ -4128,49 +620,14 @@ export class Wallet { async getWithdrawalInfo( talerWithdrawUri: string, ): Promise<DownloadedWithdrawInfo> { - const uriResult = parseWithdrawUri(talerWithdrawUri); - if (!uriResult) { - throw Error("can't parse URL"); - } - const resp = await this.http.get(uriResult.statusUrl); - console.log("resp:", resp.responseJson); - const status = WithdrawOperationStatusResponse.checked(resp.responseJson); - return { - amount: Amounts.parseOrThrow(status.amount), - confirmTransferUrl: status.confirm_transfer_url, - extractedStatusUrl: uriResult.statusUrl, - selectionDone: status.selection_done, - senderWire: status.sender_wire, - suggestedExchange: status.suggested_exchange, - transferDone: status.transfer_done, - wireTypes: status.wire_types, - }; + return getWithdrawalInfo(this.ws, talerWithdrawUri); } async acceptWithdrawal( talerWithdrawUri: string, selectedExchange: string, ): Promise<AcceptWithdrawalResponse> { - const withdrawInfo = await this.getWithdrawalInfo(talerWithdrawUri); - const exchangeWire = await this.getExchangePaytoUri( - selectedExchange, - withdrawInfo.wireTypes, - ); - const reserve = await this.createReserve({ - amount: withdrawInfo.amount, - bankWithdrawStatusUrl: withdrawInfo.extractedStatusUrl, - exchange: selectedExchange, - 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, - }; + return acceptWithdrawal(this.ws, talerWithdrawUri, selectedExchange); } async getPurchaseDetails(hc: string): Promise<PurchaseDetails> { diff --git a/src/walletTypes.ts b/src/walletTypes.ts index 45560694e..5736282ec 100644 --- a/src/walletTypes.ts +++ b/src/walletTypes.ts @@ -25,16 +25,17 @@ /** * Imports. */ -import { Checkable } from "./checkable"; -import * as LibtoolVersion from "./libtoolVersion"; +import { Checkable } from "./util/checkable"; +import * as LibtoolVersion from "./util/libtoolVersion"; -import { AmountJson } from "./amounts"; +import { AmountJson } from "./util/amounts"; import { CoinRecord, DenominationRecord, ExchangeRecord, ExchangeWireInfo, + WithdrawalSource, } from "./dbTypes"; import { CoinPaySig, ContractTerms, PayReq } from "./talerTypes"; @@ -413,6 +414,7 @@ export interface TipStatus { nextUrl: string; exchangeUrl: string; tipId: string; + merchantTipId: string; merchantOrigin: string; expirationTimestamp: number; timestamp: number; @@ -523,7 +525,7 @@ export interface WalletDiagnostics { export interface PendingWithdrawOperation { type: "withdraw"; - reservePub: string; + source: WithdrawalSource, withdrawSessionId: string; numCoinsWithdrawn: number; numCoinsTotal: number; @@ -576,13 +578,6 @@ export interface PendingRefreshOperation { refreshOutputSize: number; } -export interface PendingPlanchetOperation { - type: "planchet"; - coinPub: string; - reservePub: string; - lastError?: OperationError; -} - export interface PendingDirtyCoinOperation { type: "dirty-coin"; coinPub: string; @@ -595,14 +590,21 @@ export interface PendingProposalOperation { proposalId: string; } +export interface PendingTipOperation { + type: "tip"; + tipId: string; + merchantBaseUrl: string; + merchantTipId: string; +} + export type PendingOperationInfo = | PendingWithdrawOperation | PendingReserveOperation | PendingBugOperation - | PendingPlanchetOperation | PendingDirtyCoinOperation | PendingExchangeUpdateOperation | PendingRefreshOperation + | PendingTipOperation | PendingProposalOperation; export interface PendingOperationsResponse { @@ -642,7 +644,6 @@ export function getTimestampNow(): Timestamp { }; } - export interface PlanchetCreationResult { coinPub: string; coinPriv: string; @@ -652,6 +653,13 @@ export interface PlanchetCreationResult { blindingKey: string; withdrawSig: string; coinEv: string; - exchangeBaseUrl: string; coinValue: AmountJson; +} + +export interface PlanchetCreationRequest { + value: AmountJson; + feeWithdraw: AmountJson; + denomPub: string; + reservePub: string; + reservePriv: string; }
\ No newline at end of file diff --git a/src/webex/messages.ts b/src/webex/messages.ts index e321e5ac1..cf409b44e 100644 --- a/src/webex/messages.ts +++ b/src/webex/messages.ts @@ -21,7 +21,7 @@ // Messages are already documented in wxApi. /* tslint:disable:completed-docs */ -import { AmountJson } from "../amounts"; +import { AmountJson } from "../util/amounts"; import * as dbTypes from "../dbTypes"; import * as talerTypes from "../talerTypes"; import * as walletTypes from "../walletTypes"; @@ -113,10 +113,6 @@ export interface MessageMap { request: { reservePub: string }; response: dbTypes.ReserveRecord[]; }; - "get-planchets": { - request: { exchangeBaseUrl: string }; - response: dbTypes.PlanchetRecord[]; - }; "get-denoms": { request: { exchangeBaseUrl: string }; response: dbTypes.DenominationRecord[]; @@ -153,14 +149,6 @@ export interface MessageMap { request: {}; response: void; }; - "download-proposal": { - request: { url: string }; - response: number; - }; - "submit-pay": { - request: { contractTermsHash: string; sessionId: string | undefined }; - response: walletTypes.ConfirmPayResult; - }; "accept-refund": { request: { refundUrl: string }; response: string; diff --git a/src/webex/notify.ts b/src/webex/notify.ts index 4e53c3e1f..61a96cb1b 100644 --- a/src/webex/notify.ts +++ b/src/webex/notify.ts @@ -24,8 +24,6 @@ /** * Imports. */ -import URI = require("urijs"); - import wxApi = require("./wxApi"); declare var cloneInto: any; @@ -180,25 +178,19 @@ function registerHandlers() { }); addHandler("taler-create-reserve", (msg: any) => { - const params = { - amount: JSON.stringify(msg.amount), - bank_url: document.location.href, - callback_url: new URI(msg.callback_url) .absoluteTo(document.location.href), - suggested_exchange_url: msg.suggested_exchange_url, - wt_types: JSON.stringify(msg.wt_types), - }; - const uri = new URI(chrome.extension.getURL("/src/webex/pages/confirm-create-reserve.html")); - const redirectUrl = uri.query(params).href(); - window.location.href = redirectUrl; + const uri = new URL(chrome.extension.getURL("/src/webex/pages/confirm-create-reserve.html")); + uri.searchParams.set("amount", JSON.stringify(msg.amount)); + uri.searchParams.set("bank_url", document.location.href); + uri.searchParams.set("callback_url", new URL(msg.callback_url, document.location.href).href); + uri.searchParams.set("suggested_exchange_url", msg.suggested_exchange_url); + uri.searchParams.set("wt_types", JSON.stringify(msg.wt_types)); + window.location.href = uri.href; }); addHandler("taler-add-auditor", (msg: any) => { - const params = { - req: JSON.stringify(msg), - }; - const uri = new URI(chrome.extension.getURL("/src/webex/pages/add-auditor.html")); - const redirectUrl = uri.query(params).href(); - window.location.href = redirectUrl; + const uri = new URL(chrome.extension.getURL("/src/webex/pages/add-auditor.html")); + uri.searchParams.set("req", JSON.stringify(msg)) + window.location.href = uri.href; }); addHandler("taler-confirm-reserve", async (msg: any, sendResponse: any) => { diff --git a/src/webex/pages/add-auditor.tsx b/src/webex/pages/add-auditor.tsx index 7e3e06322..766db9c5d 100644 --- a/src/webex/pages/add-auditor.tsx +++ b/src/webex/pages/add-auditor.tsx @@ -23,7 +23,6 @@ import { CurrencyRecord } from "../../dbTypes"; import { getCurrencies, updateCurrency } from "../wxApi"; import React, { useState } from "react"; -import URI = require("urijs"); import { registerMountPage } from "../renderHtml"; interface ConfirmAuditorProps { @@ -118,14 +117,24 @@ function ConfirmAuditor(props: ConfirmAuditorProps) { registerMountPage(() => { - const walletPageUrl = new URI(document.location.href); - const query: any = JSON.parse( - (URI.parseQuery(walletPageUrl.query()) as any).req, - ); - const url = query.url; - const currency: string = query.currency; - const auditorPub: string = query.auditorPub; - const expirationStamp = Number.parseInt(query.expirationStamp); + const walletPageUrl = new URL(document.location.href); + const url = walletPageUrl.searchParams.get("url"); + if (!url) { + throw Error("missign parameter (url)"); + } + const currency = walletPageUrl.searchParams.get("currency"); + if (!currency) { + throw Error("missing parameter (currency)"); + } + const auditorPub = walletPageUrl.searchParams.get("auditorPub"); + if (!auditorPub) { + throw Error("missing parameter (auditorPub)"); + } + const auditorStampStr = walletPageUrl.searchParams.get("expirationStamp"); + if (!auditorStampStr) { + throw Error("missing parameter (auditorStampStr)"); + } + const expirationStamp = Number.parseInt(auditorStampStr); const args = { url, currency, auditorPub, expirationStamp }; return <ConfirmAuditor {...args}/>; }); diff --git a/src/webex/pages/pay.tsx b/src/webex/pages/pay.tsx index 7f2a174b7..cff2f9461 100644 --- a/src/webex/pages/pay.tsx +++ b/src/webex/pages/pay.tsx @@ -30,9 +30,8 @@ import { renderAmount, ProgressButton, registerMountPage } from "../renderHtml"; import * as wxApi from "../wxApi"; import React, { useState, useEffect } from "react"; -import URI = require("urijs"); -import * as Amounts from "../../amounts"; +import * as Amounts from "../../util/amounts"; function TalerPayDialog({ talerPayUri }: { talerPayUri: string }) { const [payStatus, setPayStatus] = useState<PreparePayResult | undefined>(); @@ -164,10 +163,10 @@ function TalerPayDialog({ talerPayUri }: { talerPayUri: string }) { } registerMountPage(() => { - const url = new URI(document.location.href); - const query: any = URI.parseQuery(url.query()); - - let talerPayUri = query.talerPayUri; - + const url = new URL(document.location.href); + const talerPayUri = url.searchParams.get("talerPayUri"); + if (!talerPayUri) { + throw Error("invalid parameter"); + } return <TalerPayDialog talerPayUri={talerPayUri} />; }); diff --git a/src/webex/pages/popup.tsx b/src/webex/pages/popup.tsx index 78b7374b3..27d5dddba 100644 --- a/src/webex/pages/popup.tsx +++ b/src/webex/pages/popup.tsx @@ -26,8 +26,8 @@ */ import * as i18n from "../../i18n"; -import { AmountJson } from "../../amounts"; -import * as Amounts from "../../amounts"; +import { AmountJson } from "../../util/amounts"; +import * as Amounts from "../../util/amounts"; import { HistoryEvent, @@ -44,9 +44,6 @@ import { import * as wxApi from "../wxApi"; import * as React from "react"; -import * as ReactDOM from "react-dom"; - -import URI = require("urijs"); function onUpdateNotification(f: () => void): () => void { const port = chrome.runtime.connect({ name: "notifications" }); @@ -339,7 +336,7 @@ function formatHistoryItem(historyItem: HistoryEvent) { </i18n.Translate> ); case "confirm-reserve": { - const exchange = new URI(d.exchangeBaseUrl).host(); + const exchange = new URL(d.exchangeBaseUrl).host; const pub = abbrev(d.reservePub); return ( <i18n.Translate wrap="p"> @@ -359,7 +356,7 @@ function formatHistoryItem(historyItem: HistoryEvent) { } case "depleted-reserve": { const exchange = d.exchangeBaseUrl - ? new URI(d.exchangeBaseUrl).host() + ? new URL(d.exchangeBaseUrl).host : "??"; const amount = renderAmount(d.requestedAmount); const pub = abbrev(d.reservePub); @@ -396,11 +393,10 @@ function formatHistoryItem(historyItem: HistoryEvent) { ); } case "tip": { - const tipPageUrl = new URI( - chrome.extension.getURL("/src/webex/pages/tip.html"), - ); - const params = { tip_id: d.tipId, merchant_domain: d.merchantDomain }; - const url = tipPageUrl.query(params).href(); + const tipPageUrl = new URL(chrome.extension.getURL("/src/webex/pages/tip.html")); + tipPageUrl.searchParams.set("tip_id", d.tipId); + tipPageUrl.searchParams.set("merchant_domain", d.merchantDomain); + const url = tipPageUrl.href; const tipLink = <a href={url} onClick={openTab(url)}>{i18n.str`tip`}</a>; // i18n: Tip return ( diff --git a/src/webex/pages/refund.tsx b/src/webex/pages/refund.tsx index 79cadcdc9..5196c9ea6 100644 --- a/src/webex/pages/refund.tsx +++ b/src/webex/pages/refund.tsx @@ -22,7 +22,6 @@ import React, { useEffect, useState } from "react"; import ReactDOM from "react-dom"; -import URI = require("urijs"); import * as wxApi from "../wxApi"; import { PurchaseDetails } from "../../walletTypes"; @@ -76,8 +75,7 @@ function RefundStatusView(props: { talerRefundUri: string }) { } async function main() { - const url = new URI(document.location.href); - const query: any = URI.parseQuery(url.query()); + const url = new URL(document.location.href); const container = document.getElementById("container"); if (!container) { @@ -85,7 +83,7 @@ async function main() { return; } - const talerRefundUri = query.talerRefundUri; + const talerRefundUri = url.searchParams.get("talerRefundUri"); if (!talerRefundUri) { console.error("taler refund URI requred"); return; diff --git a/src/webex/pages/return-coins.tsx b/src/webex/pages/return-coins.tsx index b5d53c31e..be65b4121 100644 --- a/src/webex/pages/return-coins.tsx +++ b/src/webex/pages/return-coins.tsx @@ -25,8 +25,8 @@ * Imports. */ -import { AmountJson } from "../../amounts"; -import * as Amounts from "../../amounts"; +import { AmountJson } from "../../util/amounts"; +import * as Amounts from "../../util/amounts"; import { SenderWireInfos, @@ -35,7 +35,7 @@ import { import * as i18n from "../../i18n"; -import * as wire from "../../wire"; +import * as wire from "../../util/wire"; import { getBalance, diff --git a/src/webex/pages/tip.tsx b/src/webex/pages/tip.tsx index 148b8203c..ac904cf0d 100644 --- a/src/webex/pages/tip.tsx +++ b/src/webex/pages/tip.tsx @@ -23,7 +23,6 @@ import * as React from "react"; import * as ReactDOM from "react-dom"; -import URI = require("urijs"); import * as i18n from "../../i18n"; @@ -31,7 +30,7 @@ import { acceptTip, getReserveCreationInfo, getTipStatus } from "../wxApi"; import { WithdrawDetailView, renderAmount, ProgressButton } from "../renderHtml"; -import * as Amounts from "../../amounts"; +import * as Amounts from "../../util/amounts"; import { useState, useEffect } from "react"; import { TipStatus } from "../../walletTypes"; @@ -68,7 +67,7 @@ function TipDisplay(props: { talerTipUri: string }) { const accept = async () => { setLoading(true); - await acceptTip(props.talerTipUri); + await acceptTip(tipStatus.tipId); setFinished(true); }; @@ -101,9 +100,8 @@ function TipDisplay(props: { talerTipUri: string }) { async function main() { try { - const url = new URI(document.location.href); - const query: any = URI.parseQuery(url.query()); - const talerTipUri = query.talerTipUri; + const url = new URL(document.location.href); + const talerTipUri = url.searchParams.get("talerTipUri"); if (typeof talerTipUri !== "string") { throw Error("talerTipUri must be a string"); } diff --git a/src/webex/pages/withdraw.tsx b/src/webex/pages/withdraw.tsx index 39b27f2d8..6b7152dc2 100644 --- a/src/webex/pages/withdraw.tsx +++ b/src/webex/pages/withdraw.tsx @@ -32,7 +32,6 @@ import { WithdrawDetailView, renderAmount } from "../renderHtml"; import React, { useState, useEffect } from "react"; import * as ReactDOM from "react-dom"; -import URI = require("urijs"); import { getWithdrawDetails, acceptWithdrawal } from "../wxApi"; function NewExchangeSelection(props: { talerWithdrawUri: string }) { @@ -199,9 +198,8 @@ function NewExchangeSelection(props: { talerWithdrawUri: string }) { async function main() { try { - const url = new URI(document.location.href); - const query: any = URI.parseQuery(url.query()); - let talerWithdrawUri = query.talerWithdrawUri; + const url = new URL(document.location.href); + const talerWithdrawUri = url.searchParams.get("talerWithdrawUri"); if (!talerWithdrawUri) { throw Error("withdraw URI required"); } diff --git a/src/webex/renderHtml.tsx b/src/webex/renderHtml.tsx index 42bcdbabc..945719b65 100644 --- a/src/webex/renderHtml.tsx +++ b/src/webex/renderHtml.tsx @@ -23,8 +23,8 @@ /** * Imports. */ -import { AmountJson } from "../amounts"; -import * as Amounts from "../amounts"; +import { AmountJson } from "../util/amounts"; +import * as Amounts from "../util/amounts"; import { DenominationRecord } from "../dbTypes"; import { ReserveCreationInfo } from "../walletTypes"; import * as moment from "moment"; diff --git a/src/webex/wxApi.ts b/src/webex/wxApi.ts index a8b35ed34..ea26cd2eb 100644 --- a/src/webex/wxApi.ts +++ b/src/webex/wxApi.ts @@ -22,7 +22,7 @@ /** * Imports. */ -import { AmountJson } from "../amounts"; +import { AmountJson } from "../util/amounts"; import { CoinRecord, CurrencyRecord, @@ -174,14 +174,6 @@ export function getCoins(exchangeBaseUrl: string): Promise<CoinRecord[]> { /** - * Get all planchets withdrawn from the given exchange. - */ -export function getPlanchets(exchangeBaseUrl: string): Promise<PlanchetRecord[]> { - return callBackend("get-planchets", { exchangeBaseUrl }); -} - - -/** * Get all denoms offered by the given exchange. */ export function getDenoms(exchangeBaseUrl: string): Promise<DenominationRecord[]> { @@ -211,13 +203,6 @@ export function confirmPay(proposalId: string, sessionId: string | undefined): P return callBackend("confirm-pay", { proposalId, sessionId }); } -/** - * Replay paying for a purchase. - */ -export function submitPay(contractTermsHash: string, sessionId: string | undefined): Promise<ConfirmPayResult> { - return callBackend("submit-pay", { contractTermsHash, sessionId }); -} - /** * Mark a reserve as confirmed. @@ -302,14 +287,6 @@ export function clearNotification(): Promise<void> { return callBackend("clear-notification", { }); } - -/** - * Download a contract. - */ -export function downloadProposal(url: string): Promise<number> { - return callBackend("download-proposal", { url }); -} - /** * Download a refund and accept it. */ diff --git a/src/webex/wxBackend.ts b/src/webex/wxBackend.ts index 78c86a976..2d7f963e9 100644 --- a/src/webex/wxBackend.ts +++ b/src/webex/wxBackend.ts @@ -23,8 +23,8 @@ /** * Imports. */ -import { BrowserHttpLib } from "../http"; -import { AmountJson } from "../amounts"; +import { BrowserHttpLib } from "../util/http"; +import { AmountJson } from "../util/amounts"; import { ConfirmReserveRequest, CreateReserveRequest, @@ -39,11 +39,10 @@ import { openTalerDb, exportDb, importDb, deleteDb } from "../db"; import { ChromeBadge } from "./chromeBadge"; import { MessageType } from "./messages"; import * as wxApi from "./wxApi"; -import URI = require("urijs"); import Port = chrome.runtime.Port; import MessageSender = chrome.runtime.MessageSender; import { BrowserCryptoWorkerFactory } from "../crypto/cryptoApi"; -import { OpenedPromise, openPromise } from "../promiseUtils"; +import { OpenedPromise, openPromise } from "../util/promiseUtils"; const NeedsWallet = Symbol("NeedsWallet"); @@ -122,15 +121,6 @@ async function handleMessage( } return needsWallet().confirmPay(detail.proposalId, detail.sessionId); } - case "submit-pay": { - if (typeof detail.contractTermsHash !== "string") { - throw Error("contractTermsHash must be a string"); - } - return needsWallet().submitPay( - detail.contractTermsHash, - detail.sessionId, - ); - } case "exchange-info": { if (!detail.baseUrl) { return Promise.resolve({ error: "bad url" }); @@ -170,7 +160,7 @@ async function handleMessage( if (typeof detail.reservePub !== "string") { return Promise.reject(Error("reservePub missing")); } - return needsWallet().withdrawPaybackReserve(detail.reservePub); + throw Error("not implemented"); } case "get-coins": { if (typeof detail.exchangeBaseUrl !== "string") { @@ -178,12 +168,6 @@ async function handleMessage( } return needsWallet().getCoinsForExchange(detail.exchangeBaseUrl); } - case "get-planchets": { - if (typeof detail.exchangeBaseUrl !== "string") { - return Promise.reject(Error("exchangBaseUrl missing")); - } - return needsWallet().getPlanchets(detail.exchangeBaseUrl); - } case "get-denoms": { if (typeof detail.exchangeBaseUrl !== "string") { return Promise.reject(Error("exchangBaseUrl missing")); @@ -244,9 +228,6 @@ async function handleMessage( case "clear-notification": { return needsWallet().clearNotification(); } - case "download-proposal": { - return needsWallet().downloadProposal(detail.url); - } case "abort-failed-payment": { if (!detail.contractTermsHash) { throw Error("contracTermsHash not given"); @@ -404,18 +385,19 @@ function makeSyncWalletRedirect( oldUrl: string, params?: { [name: string]: string | undefined }, ): object { - const innerUrl = new URI(chrome.extension.getURL("/src/webex/pages/" + url)); + const innerUrl = new URL(chrome.extension.getURL("/src/webex/pages/" + url)); if (params) { for (const key in params) { - if (params[key]) { - innerUrl.addSearch(key, params[key]); + const p = params[key]; + if (p) { + innerUrl.searchParams.set(key, p); } } } - const outerUrl = new URI( + const outerUrl = new URL( chrome.extension.getURL("/src/webex/pages/redirect.html"), ); - outerUrl.addSearch("url", innerUrl); + outerUrl.searchParams.set("url", innerUrl.href); if (isFirefox()) { // Some platforms don't support the sync redirect (yet), so fall back to // async redirect after a timeout. @@ -423,12 +405,12 @@ function makeSyncWalletRedirect( await waitMs(150); const tab = await getTab(tabId); if (tab.url === oldUrl) { - chrome.tabs.update(tabId, { url: outerUrl.href() }); + chrome.tabs.update(tabId, { url: outerUrl.href }); } }; doit(); } - return { redirectUrl: outerUrl.href() }; + return { redirectUrl: outerUrl.href }; } /** @@ -549,29 +531,29 @@ export async function wxMain() { if (!tab.url || !tab.id) { continue; } - const uri = new URI(tab.url); - if (uri.protocol() !== "http" && uri.protocol() !== "https") { + const uri = new URL(tab.url); + if (uri.protocol !== "http:" && uri.protocol !== "https:") { continue; } console.log( "injecting into existing tab", tab.id, "with url", - uri.href(), + uri.href, "protocol", - uri.protocol(), + uri.protocol, ); injectScript( tab.id, { file: "/dist/contentScript-bundle.js", runAt: "document_start" }, - uri.href(), + uri.href, ); const code = ` if (("taler" in window) || document.documentElement.getAttribute("data-taler-nojs")) { document.dispatchEvent(new Event("taler-probe-result")); } `; - injectScript(tab.id, { code, runAt: "document_start" }, uri.href()); + injectScript(tab.id, { code, runAt: "document_start" }, uri.href); } }); @@ -603,8 +585,8 @@ export async function wxMain() { if (!tab.url || !tab.id) { return; } - const uri = new URI(tab.url); - if (!(uri.protocol() === "http" || uri.protocol() === "https")) { + const uri = new URL(tab.url); + if (!(uri.protocol === "http:" || uri.protocol === "https:")) { return; } const code = ` @@ -612,7 +594,7 @@ export async function wxMain() { document.dispatchEvent(new Event("taler-probe-result")); } `; - injectScript(tab.id!, { code, runAt: "document_start" }, uri.href()); + injectScript(tab.id!, { code, runAt: "document_start" }, uri.href); }); }; |