aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--package.json6
-rw-r--r--src/android/index.ts6
-rw-r--r--src/crypto/cryptoApi.ts12
-rw-r--r--src/crypto/cryptoImplementation.ts25
-rw-r--r--src/db.ts10
-rw-r--r--src/dbTypes.ts116
-rw-r--r--src/headless/bank.ts11
-rw-r--r--src/headless/helpers.ts10
-rw-r--r--src/headless/merchant.ts13
-rw-r--r--src/headless/taler-wallet-cli.ts7
-rw-r--r--src/talerTypes.ts6
-rw-r--r--src/types-test.ts78
-rw-r--r--src/util/amounts.ts (renamed from src/amounts.ts)0
-rw-r--r--src/util/assertUnreachable.ts19
-rw-r--r--src/util/asyncMemo.ts52
-rw-r--r--src/util/checkable.ts (renamed from src/checkable.ts)0
-rw-r--r--src/util/helpers-test.ts (renamed from src/helpers-test.ts)0
-rw-r--r--src/util/helpers.ts (renamed from src/helpers.ts)15
-rw-r--r--src/util/http.ts (renamed from src/http.ts)0
-rw-r--r--src/util/libtoolVersion-test.ts (renamed from src/libtoolVersion-test.ts)0
-rw-r--r--src/util/libtoolVersion.ts (renamed from src/libtoolVersion.ts)0
-rw-r--r--src/util/logging.ts (renamed from src/logging.ts)2
-rw-r--r--src/util/payto-test.ts31
-rw-r--r--src/util/payto.ts54
-rw-r--r--src/util/promiseUtils.ts (renamed from src/promiseUtils.ts)0
-rw-r--r--src/util/query.ts (renamed from src/query.ts)0
-rw-r--r--src/util/taleruri-test.ts (renamed from src/taleruri-test.ts)100
-rw-r--r--src/util/taleruri.ts (renamed from src/taleruri.ts)118
-rw-r--r--src/util/timer.ts (renamed from src/timer.ts)0
-rw-r--r--src/util/wire.ts (renamed from src/wire.ts)2
-rw-r--r--src/wallet-impl/balance.ts144
-rw-r--r--src/wallet-impl/exchanges.ts401
-rw-r--r--src/wallet-impl/history.ts172
-rw-r--r--src/wallet-impl/pay.ts822
-rw-r--r--src/wallet-impl/payback.ts88
-rw-r--r--src/wallet-impl/pending.ts208
-rw-r--r--src/wallet-impl/refresh.ts416
-rw-r--r--src/wallet-impl/refund.ts245
-rw-r--r--src/wallet-impl/reserves.ts567
-rw-r--r--src/wallet-impl/return.ts274
-rw-r--r--src/wallet-impl/state.ts32
-rw-r--r--src/wallet-impl/tip.ts246
-rw-r--r--src/wallet-impl/withdraw.ts577
-rw-r--r--src/wallet-test.ts44
-rw-r--r--src/wallet.ts3769
-rw-r--r--src/walletTypes.ts36
-rw-r--r--src/webex/messages.ts14
-rw-r--r--src/webex/notify.ts28
-rw-r--r--src/webex/pages/add-auditor.tsx27
-rw-r--r--src/webex/pages/pay.tsx13
-rw-r--r--src/webex/pages/popup.tsx20
-rw-r--r--src/webex/pages/refund.tsx6
-rw-r--r--src/webex/pages/return-coins.tsx6
-rw-r--r--src/webex/pages/tip.tsx10
-rw-r--r--src/webex/pages/withdraw.tsx6
-rw-r--r--src/webex/renderHtml.tsx4
-rw-r--r--src/webex/wxApi.ts25
-rw-r--r--src/webex/wxBackend.ts60
-rw-r--r--tsconfig.json47
-rw-r--r--yarn.lock10
60 files changed, 4870 insertions, 4140 deletions
diff --git a/package.json b/package.json
index fcff69c99..c87efd4fa 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,7 @@
"devDependencies": {
"@types/react": "^16.4.0",
"@types/react-dom": "^16.0.0",
+ "@types/chrome": "^0.0.91",
"ava": "^2.4.0",
"awesome-typescript-loader": "^5.2.1",
"glob": "^7.1.1",
@@ -60,13 +61,10 @@
"webpack-merge": "^4.2.2"
},
"dependencies": {
- "@types/chrome": "^0.0.91",
- "@types/urijs": "^1.19.3",
"axios": "^0.19.0",
"big-integer": "^1.6.48",
"idb-bridge": "^0.0.15",
"qrcode-generator": "^1.4.3",
- "source-map-support": "^0.5.12",
- "urijs": "^1.18.10"
+ "source-map-support": "^0.5.12"
}
}
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),
};
diff --git a/src/db.ts b/src/db.ts
index e317b0aaf..ddf3771b7 100644
--- a/src/db.ts
+++ b/src/db.ts
@@ -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);
});
};
diff --git a/tsconfig.json b/tsconfig.json
index 8df947649..75214637e 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -23,9 +23,7 @@
"esModuleInterop": true
},
"files": [
- "src/amounts.ts",
"src/android/index.ts",
- "src/checkable.ts",
"src/crypto/browserWorkerEntry.ts",
"src/crypto/cryptoApi.ts",
"src/crypto/cryptoImplementation.ts",
@@ -46,22 +44,42 @@
"src/headless/integrationtest.ts",
"src/headless/merchant.ts",
"src/headless/taler-wallet-cli.ts",
- "src/helpers-test.ts",
- "src/helpers.ts",
- "src/http.ts",
"src/i18n.tsx",
"src/i18n/strings.ts",
"src/index.ts",
- "src/libtoolVersion-test.ts",
- "src/libtoolVersion.ts",
- "src/logging.ts",
- "src/promiseUtils.ts",
- "src/query.ts",
"src/talerTypes.ts",
- "src/taleruri-test.ts",
- "src/taleruri.ts",
- "src/timer.ts",
"src/types-test.ts",
+ "src/util/amounts.ts",
+ "src/util/assertUnreachable.ts",
+ "src/util/asyncMemo.ts",
+ "src/util/checkable.ts",
+ "src/util/helpers-test.ts",
+ "src/util/helpers.ts",
+ "src/util/http.ts",
+ "src/util/libtoolVersion-test.ts",
+ "src/util/libtoolVersion.ts",
+ "src/util/logging.ts",
+ "src/util/payto-test.ts",
+ "src/util/payto.ts",
+ "src/util/promiseUtils.ts",
+ "src/util/query.ts",
+ "src/util/taleruri-test.ts",
+ "src/util/taleruri.ts",
+ "src/util/timer.ts",
+ "src/util/wire.ts",
+ "src/wallet-impl/balance.ts",
+ "src/wallet-impl/exchanges.ts",
+ "src/wallet-impl/history.ts",
+ "src/wallet-impl/pay.ts",
+ "src/wallet-impl/payback.ts",
+ "src/wallet-impl/pending.ts",
+ "src/wallet-impl/refresh.ts",
+ "src/wallet-impl/refund.ts",
+ "src/wallet-impl/reserves.ts",
+ "src/wallet-impl/return.ts",
+ "src/wallet-impl/state.ts",
+ "src/wallet-impl/tip.ts",
+ "src/wallet-impl/withdraw.ts",
"src/wallet-test.ts",
"src/wallet.ts",
"src/walletTypes.ts",
@@ -86,7 +104,6 @@
"src/webex/pages/withdraw.tsx",
"src/webex/renderHtml.tsx",
"src/webex/wxApi.ts",
- "src/webex/wxBackend.ts",
- "src/wire.ts"
+ "src/webex/wxBackend.ts"
]
} \ No newline at end of file
diff --git a/yarn.lock b/yarn.lock
index 22bcd5fd3..a28bb07c0 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -394,11 +394,6 @@
"@types/prop-types" "*"
csstype "^2.2.0"
-"@types/urijs@^1.19.3":
- version "1.19.4"
- resolved "https://registry.yarnpkg.com/@types/urijs/-/urijs-1.19.4.tgz#29c4a694d4842d7f95e359a26223fc1865f1ab13"
- integrity sha512-uHUvuLfy4YkRHL4UH8J8oRsINhdEHd9ymag7KJZVT94CjAmY1njoUzhazJsZjwfy+IpWKQKGVyXCwzhZvg73Fg==
-
"@webassemblyjs/ast@1.8.5":
version "1.8.5"
resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.8.5.tgz#51b1c5fe6576a34953bf4b253df9f0d490d9e359"
@@ -7084,11 +7079,6 @@ uri-js@^4.2.2:
dependencies:
punycode "^2.1.0"
-urijs@^1.18.10:
- version "1.19.2"
- resolved "https://registry.yarnpkg.com/urijs/-/urijs-1.19.2.tgz#f9be09f00c4c5134b7cb3cf475c1dd394526265a"
- integrity sha512-s/UIq9ap4JPZ7H1EB5ULo/aOUbWqfDi7FKzMC2Nz+0Si8GiT1rIEaprt8hy3Vy2Ex2aJPpOQv4P4DuOZ+K1c6w==
-
urix@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72"